react-email 3.0.6 → 4.0.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/cli/index.js +768 -763
  3. package/dist/cli/index.mjs +480 -476
  4. package/dist/preview/.next/BUILD_ID +1 -1
  5. package/dist/preview/.next/app-build-manifest.json +14 -12
  6. package/dist/preview/.next/build-manifest.json +5 -5
  7. package/dist/preview/.next/cache/.rscinfo +1 -1
  8. package/dist/preview/.next/cache/webpack/client-production/0.pack +0 -0
  9. package/dist/preview/.next/cache/webpack/client-production/index.pack +0 -0
  10. package/dist/preview/.next/cache/webpack/edge-server-production/index.pack +0 -0
  11. package/dist/preview/.next/cache/webpack/server-production/0.pack +0 -0
  12. package/dist/preview/.next/cache/webpack/server-production/index.pack +0 -0
  13. package/dist/preview/.next/next-minimal-server.js.nft.json +1 -1
  14. package/dist/preview/.next/next-server.js.nft.json +1 -1
  15. package/dist/preview/.next/prerender-manifest.json +1 -1
  16. package/dist/preview/.next/server/app/_not-found/page.js +1 -1
  17. package/dist/preview/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/dist/preview/.next/server/app/page.js +1 -1
  19. package/dist/preview/.next/server/app/page.js.nft.json +1 -1
  20. package/dist/preview/.next/server/app/page_client-reference-manifest.js +1 -1
  21. package/dist/preview/.next/server/app/preview/[...slug]/page.js +6 -6
  22. package/dist/preview/.next/server/app/preview/[...slug]/page.js.nft.json +1 -1
  23. package/dist/preview/.next/server/app/preview/[...slug]/page_client-reference-manifest.js +1 -1
  24. package/dist/preview/.next/server/chunks/273.js +1 -0
  25. package/dist/preview/.next/server/chunks/594.js +10 -0
  26. package/dist/preview/.next/server/middleware-build-manifest.js +1 -1
  27. package/dist/preview/.next/server/pages/500.html +1 -1
  28. package/dist/preview/.next/server/server-reference-manifest.js +1 -1
  29. package/dist/preview/.next/server/server-reference-manifest.json +1 -1
  30. package/dist/preview/.next/server/webpack-runtime.js +1 -1
  31. package/dist/preview/.next/static/chunks/18b16e15-6ad9b58e10ff8891.js +1 -0
  32. package/dist/preview/.next/static/chunks/490-48951f2e19ae3aef.js +1 -0
  33. package/dist/preview/.next/static/chunks/600-2e2ca4c8bbd97b61.js +1 -0
  34. package/dist/preview/.next/static/chunks/app/{layout-a2901ed1c2c53661.js → layout-490964e2c3604d33.js} +1 -1
  35. package/dist/preview/.next/static/chunks/app/page-d2432acd08db8fc0.js +1 -0
  36. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-f4e211e00c026401.js +1 -0
  37. package/dist/preview/.next/static/chunks/webpack-7bf1ffb05f5540be.js +1 -0
  38. package/dist/preview/.next/static/css/5e0736cafbb392a9.css +3 -0
  39. package/dist/preview/.next/trace +21 -21
  40. package/package.json +12 -7
  41. package/postcss.config.js +1 -1
  42. package/src/actions/email-validation/check-links.ts +88 -0
  43. package/src/actions/email-validation/get-line-and-column-from-index.spec.ts +22 -0
  44. package/src/actions/email-validation/get-line-and-column-from-index.ts +43 -0
  45. package/src/actions/email-validation/quick-fetch.ts +12 -0
  46. package/src/actions/get-email-path-from-slug.ts +7 -4
  47. package/src/actions/render-email-by-path.tsx +3 -3
  48. package/src/animated-icons-data/help.json +1082 -0
  49. package/src/animated-icons-data/link.json +1309 -0
  50. package/src/animated-icons-data/load.json +443 -0
  51. package/src/animated-icons-data/mail.json +1320 -0
  52. package/src/app/globals.css +0 -24
  53. package/src/app/layout.tsx +7 -3
  54. package/src/app/page.tsx +9 -10
  55. package/src/app/preview/[...slug]/page.tsx +3 -2
  56. package/src/app/preview/[...slug]/preview.tsx +5 -5
  57. package/src/app/preview/[...slug]/rendering-error.tsx +6 -6
  58. package/src/components/button.tsx +8 -8
  59. package/src/components/code-container.tsx +7 -7
  60. package/src/components/code-snippet.tsx +11 -0
  61. package/src/components/code.tsx +4 -4
  62. package/src/components/heading.tsx +1 -1
  63. package/src/components/icons/icon-button.tsx +1 -1
  64. package/src/components/icons/icon-circle-check.tsx +21 -0
  65. package/src/components/icons/icon-circle-close.tsx +17 -0
  66. package/src/components/icons/icon-circle-warning.tsx +17 -0
  67. package/src/components/icons/icon-email.tsx +18 -0
  68. package/src/components/icons/icon-link.tsx +14 -0
  69. package/src/components/icons/icon-stamp.tsx +14 -0
  70. package/src/components/send.tsx +9 -9
  71. package/src/components/shell.tsx +32 -34
  72. package/src/components/sidebar/{sidebar-directory-children.tsx → file-tree-directory-children.tsx} +22 -18
  73. package/src/components/sidebar/{sidebar-directory.tsx → file-tree-directory.tsx} +11 -12
  74. package/src/components/sidebar/file-tree.tsx +31 -0
  75. package/src/components/sidebar/link-checker.tsx +291 -0
  76. package/src/components/sidebar/sidebar.tsx +296 -22
  77. package/src/components/text.tsx +1 -1
  78. package/src/components/tooltip-content.tsx +3 -3
  79. package/src/components/tooltip.tsx +1 -1
  80. package/src/components/topbar.tsx +14 -17
  81. package/src/hooks/use-email-rendering-result.ts +2 -2
  82. package/src/hooks/use-icon-animation.ts +44 -0
  83. package/src/utils/cn.ts +1 -1
  84. package/src/utils/esbuild/renderring-utilities-exporter.ts +1 -1
  85. package/src/utils/get-email-component.ts +6 -6
  86. package/src/utils/get-emails-directory-metadata.spec.ts +0 -1
  87. package/src/utils/improve-error-with-sourcemap.ts +1 -1
  88. package/src/utils/static-node-modules-for-vm.ts +6 -6
  89. package/tsconfig.json +2 -6
  90. package/.eslintrc.js +0 -52
  91. package/.prettierignore +0 -3
  92. package/.prettierrc.js +0 -8
  93. package/dist/preview/.next/cache/eslint/.cache_1c3sgg +0 -1
  94. package/dist/preview/.next/server/chunks/391.js +0 -1
  95. package/dist/preview/.next/server/chunks/720.js +0 -10
  96. package/dist/preview/.next/static/chunks/12-b9450aa0845e7574.js +0 -1
  97. package/dist/preview/.next/static/chunks/154-4202f86af36ccff4.js +0 -1
  98. package/dist/preview/.next/static/chunks/app/page-54a86772095e22e0.js +0 -1
  99. package/dist/preview/.next/static/chunks/app/preview/[...slug]/page-2bfad134b65ddd79.js +0 -1
  100. package/dist/preview/.next/static/chunks/webpack-9255716c9496e606.js +0 -1
  101. package/dist/preview/.next/static/css/eb0a93282704d7ab.css +0 -3
  102. /package/dist/preview/.next/static/{Trk1e7GzgKOLunAXBDCy- → fZaiKz58wDr55pxLu9uHa}/_buildManifest.js +0 -0
  103. /package/dist/preview/.next/static/{Trk1e7GzgKOLunAXBDCy- → fZaiKz58wDr55pxLu9uHa}/_ssgManifest.js +0 -0
@@ -1,4 +1,5 @@
1
1
  'use client';
2
+
2
3
  import * as React from 'react';
3
4
  import { cn } from '../utils';
4
5
  import { Logo } from './logo';
@@ -27,14 +28,13 @@ export const Shell = ({
27
28
  const [triggerTransition, setTriggerTransition] = React.useState(false);
28
29
 
29
30
  return (
30
- <div className="flex bg-black text-white flex-col h-screen overflow-x-hidden">
31
- <div className="flex lg:hidden items-center px-6 justify-between h-[70px] border-b border-slate-6">
32
- <div className="h-[70px] flex items-center">
31
+ <>
32
+ <div className="flex h-[4.375rem] items-center justify-between border-slate-6 border-b px-6 lg:hidden">
33
+ <div className="flex h-[4.375rem] items-center">
33
34
  <Logo />
34
35
  </div>
35
-
36
36
  <button
37
- className="h-6 w-6 rounded flex items-center justify-center text-white"
37
+ className="flex h-6 w-6 items-center justify-center rounded text-white"
38
38
  onClick={() => {
39
39
  setSidebarToggled((v) => !v);
40
40
  }}
@@ -48,6 +48,7 @@ export const Shell = ({
48
48
  width="16"
49
49
  xmlns="http://www.w3.org/2000/svg"
50
50
  >
51
+ <title>Menu</title>
51
52
  <path
52
53
  clipRule="evenodd"
53
54
  d="M1.5 3C1.22386 3 1 3.22386 1 3.5C1 3.77614 1.22386 4 1.5 4H13.5C13.7761 4 14 3.77614 14 3.5C14 3.22386 13.7761 3 13.5 3H1.5ZM1 7.5C1 7.22386 1.22386 7 1.5 7H13.5C13.7761 7 14 7.22386 14 7.5C14 7.77614 13.7761 8 13.5 8H1.5C1.22386 8 1 7.77614 1 7.5ZM1 11.5C1 11.2239 1.22386 11 1.5 11H13.5C13.7761 11 14 11.2239 14 11.5C14 11.7761 13.7761 12 13.5 12H1.5C1.22386 12 1 11.7761 1 11.5Z"
@@ -57,36 +58,34 @@ export const Shell = ({
57
58
  </svg>
58
59
  </button>
59
60
  </div>
60
-
61
- <div className="flex bg-slate-2">
61
+ <React.Suspense>
62
62
  <Sidebar
63
- className={cn(
64
- 'w-screen max-w-full bg-black h-screen lg:h-auto z-50 lg:z-auto lg:max-w-[275px] fixed top-[70px] lg:top-0 left-0',
65
- {
66
- 'translate-x-0 lg:-translate-x-full': sidebarToggled,
67
- '-translate-x-full lg:translate-x-0': !sidebarToggled,
68
- },
69
- )}
63
+ className={cn({
64
+ 'lg:-translate-x-full translate-x-0': sidebarToggled,
65
+ '-translate-x-full lg:translate-x-0': !sidebarToggled,
66
+ })}
70
67
  currentEmailOpenSlug={currentEmailOpenSlug}
68
+ markup={markup}
71
69
  style={{
72
70
  transition: triggerTransition ? 'transform 0.2s ease-in-out' : '',
73
71
  }}
74
72
  />
75
-
76
- <main
77
- className={cn(
78
- 'absolute will-change-width h-screen w-[100vw] right-0',
79
- {
80
- 'lg:translate-x-0 lg:w-[calc(100vw)]': sidebarToggled,
81
- 'lg:translate-x-0 lg:w-[calc(100vw-275px)]': !sidebarToggled,
82
- },
83
- )}
84
- style={{
85
- transition: triggerTransition
86
- ? 'width 0.2s ease-in-out, transform 0.2s ease-in-out'
87
- : '',
88
- }}
89
- >
73
+ </React.Suspense>
74
+ <main
75
+ className={cn(
76
+ 'relative h-full max-h-full min-h-screen w-[100vw] overflow-hidden will-change-width sm:mt-[4.375rem] md:absolute md:right-0 lg:mt-0',
77
+ {
78
+ 'lg:w-[calc(100vw)] lg:translate-x-0': sidebarToggled,
79
+ 'lg:w-[calc(100vw-20rem)] lg:translate-x-0': !sidebarToggled,
80
+ },
81
+ )}
82
+ style={{
83
+ transition: triggerTransition
84
+ ? 'width 0.2s ease-in-out, transform 0.2s ease-in-out'
85
+ : '',
86
+ }}
87
+ >
88
+ <div className="relative h-full w-full">
90
89
  {currentEmailOpenSlug && pathSeparator ? (
91
90
  <Topbar
92
91
  activeView={activeView}
@@ -107,12 +106,11 @@ export const Shell = ({
107
106
  setActiveView={setActiveView}
108
107
  />
109
108
  ) : null}
110
-
111
- <div className="h-[calc(100vh_-_70px)] overflow-auto mx-auto">
109
+ <div className="relative mx-auto h-[calc(100vh-3.3125rem)] grow md:h-full">
112
110
  {children}
113
111
  </div>
114
- </main>
115
- </div>
116
- </div>
112
+ </div>
113
+ </main>
114
+ </>
117
115
  );
118
116
  };
@@ -1,14 +1,14 @@
1
- import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
2
1
  import * as Collapsible from '@radix-ui/react-collapsible';
2
+ import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
3
3
  import Link from 'next/link';
4
4
  import { useSearchParams } from 'next/navigation';
5
- import type { EmailsDirectory } from '../../utils/get-emails-directory-metadata';
6
- import { emailsDirectoryAbsolutePath } from '../../utils/emails-directory-absolute-path';
7
5
  import { cn } from '../../utils';
6
+ import { emailsDirectoryAbsolutePath } from '../../utils/emails-directory-absolute-path';
7
+ import type { EmailsDirectory } from '../../utils/get-emails-directory-metadata';
8
8
  import { IconFile } from '../icons/icon-file';
9
- import { SidebarDirectory } from './sidebar-directory';
9
+ import { FileTreeDirectory } from './file-tree-directory';
10
10
 
11
- export const SidebarDirectoryChildren = (props: {
11
+ export const FileTreeDirectoryChildren = (props: {
12
12
  emailsDirectoryMetadata: EmailsDirectory;
13
13
  currentEmailOpenSlug?: string;
14
14
  open: boolean;
@@ -32,22 +32,20 @@ export const SidebarDirectoryChildren = (props: {
32
32
  initial={{ opacity: 0, height: 0 }}
33
33
  >
34
34
  {props.isRoot ? null : (
35
- <div className="line absolute left-2.5 w-px h-full bg-slate-6" />
35
+ <div className="line absolute left-2.5 h-full w-px bg-slate-6" />
36
36
  )}
37
-
38
37
  <div className="data-[root=true]:py-2 flex flex-col truncate">
39
38
  <LayoutGroup id="sidebar">
40
39
  {props.emailsDirectoryMetadata.subDirectories.map(
41
40
  (subDirectory) => (
42
- <SidebarDirectory
43
- className="pl-4 py-0"
41
+ <FileTreeDirectory
42
+ className="p-0 data-[state=open]:mb-2"
44
43
  currentEmailOpenSlug={props.currentEmailOpenSlug}
45
44
  emailsDirectoryMetadata={subDirectory}
46
45
  key={subDirectory.absolutePath}
47
46
  />
48
47
  ),
49
48
  )}
50
-
51
49
  {props.emailsDirectoryMetadata.emailFilenames.map(
52
50
  (emailFilename, index) => {
53
51
  const emailSlug = isBaseEmailsDirectory
@@ -81,7 +79,7 @@ export const SidebarDirectoryChildren = (props: {
81
79
  <motion.span
82
80
  animate={{ x: 0, opacity: 1 }}
83
81
  className={cn(
84
- 'text-[14px] flex items-center align-middle pl-3 h-8 max-w-full rounded-md text-slate-11 relative transition-colors',
82
+ 'relative flex h-8 max-w-full items-center rounded-md pl-3 align-middle text-slate-11 text-sm transition-colors duration-100 ease-[cubic-bezier(.6,.12,.34,.96)]',
85
83
  {
86
84
  'text-cyan-11': isCurrentPage,
87
85
  'hover:text-slate-12':
@@ -97,19 +95,25 @@ export const SidebarDirectoryChildren = (props: {
97
95
  {isCurrentPage ? (
98
96
  <motion.span
99
97
  animate={{ opacity: 1 }}
100
- className="absolute left-0 right-0 top-0 bottom-0 rounded-md bg-cyan-5 opacity-0"
98
+ className="absolute inset-0 rounded-md bg-cyan-5 opacity-0 transition-all duration-200 ease-[cubic-bezier(.6,.12,.34,.96)]"
101
99
  exit={{ opacity: 0 }}
102
100
  initial={{ opacity: 0 }}
103
101
  >
104
- {!props.isRoot && (
105
- <div className="bg-cyan-11 w-px absolute top-1 left-1.5 h-6" />
106
- )}
102
+ <motion.div
103
+ className="absolute top-1 left-[.625rem] h-6 w-px rounded-sm bg-cyan-11"
104
+ layoutId="active-file"
105
+ transition={{
106
+ type: 'spring',
107
+ bounce: 0.2,
108
+ duration: 0.6,
109
+ }}
110
+ />
107
111
  </motion.span>
108
112
  ) : null}
109
113
  <IconFile
110
- className="absolute left-4 w-[24px] h-[24px]"
111
- height="24"
112
- width="24"
114
+ className="absolute left-4 h-5 w-5"
115
+ height="20"
116
+ width="20"
113
117
  />
114
118
  <span className="truncate pl-8">{emailFilename}</span>
115
119
  </motion.span>
@@ -2,12 +2,12 @@
2
2
  import * as Collapsible from '@radix-ui/react-collapsible';
3
3
  import * as React from 'react';
4
4
  import { cn } from '../../utils';
5
- import { type EmailsDirectory } from '../../utils/get-emails-directory-metadata';
5
+ import type { EmailsDirectory } from '../../utils/get-emails-directory-metadata';
6
6
  import { Heading } from '../heading';
7
+ import { IconArrowDown } from '../icons/icon-arrow-down';
7
8
  import { IconFolder } from '../icons/icon-folder';
8
9
  import { IconFolderOpen } from '../icons/icon-folder-open';
9
- import { IconArrowDown } from '../icons/icon-arrow-down';
10
- import { SidebarDirectoryChildren } from './sidebar-directory-children';
10
+ import { FileTreeDirectoryChildren } from './file-tree-directory-children';
11
11
 
12
12
  interface SidebarDirectoryProps {
13
13
  emailsDirectoryMetadata: EmailsDirectory;
@@ -17,7 +17,7 @@ interface SidebarDirectoryProps {
17
17
 
18
18
  const persistedOpenDirectories = new Set<string>();
19
19
 
20
- export const SidebarDirectory = ({
20
+ export const FileTreeDirectory = ({
21
21
  emailsDirectoryMetadata: directoryMetadata,
22
22
  className,
23
23
  currentEmailOpenSlug,
@@ -51,21 +51,21 @@ export const SidebarDirectory = ({
51
51
  >
52
52
  <Collapsible.Trigger
53
53
  className={cn(
54
- 'text-[14px] flex items-center font-medium gap-2 justify-between w-full my-1',
54
+ 'mt-1 mb-1.5 flex w-full items-center justify-between gap-2 font-medium text-[14px]',
55
55
  {
56
56
  'cursor-pointer': !isEmpty,
57
57
  },
58
58
  )}
59
59
  >
60
- <div className="flex items-center text-slate-11 transition ease-in-out duration-200 hover:text-slate-12 gap-1">
60
+ <div className="flex items-center gap-2 text-slate-11 transition duration-200 ease-in-out hover:text-slate-12">
61
61
  {open ? (
62
- <IconFolderOpen height="24" width="24" />
62
+ <IconFolderOpen height="20" width="20" />
63
63
  ) : (
64
- <IconFolder height="24" width="24" />
64
+ <IconFolder height="20" width="20" />
65
65
  )}
66
66
  <Heading
67
67
  as="h3"
68
- className="transition ease-in-out duration-200 hover:text-slate-12"
68
+ className="transition duration-200 ease-in-out hover:text-slate-12"
69
69
  color="gray"
70
70
  size="2"
71
71
  weight="medium"
@@ -75,14 +75,13 @@ export const SidebarDirectory = ({
75
75
  </div>
76
76
  {!isEmpty ? (
77
77
  <IconArrowDown
78
- className="data-[open=true]:rotate-180 transition-transform opacity-60 justify-self-end"
78
+ className="justify-self-end opacity-60 transition-transform data-[open=true]:rotate-180"
79
79
  data-open={open}
80
80
  />
81
81
  ) : null}
82
82
  </Collapsible.Trigger>
83
-
84
83
  {!isEmpty ? (
85
- <SidebarDirectoryChildren
84
+ <FileTreeDirectoryChildren
86
85
  currentEmailOpenSlug={currentEmailOpenSlug}
87
86
  emailsDirectoryMetadata={directoryMetadata}
88
87
  open={open}
@@ -0,0 +1,31 @@
1
+ import * as Collapsible from '@radix-ui/react-collapsible';
2
+ import * as React from 'react';
3
+ import type { EmailsDirectory } from '../../utils/get-emails-directory-metadata';
4
+ import { FileTreeDirectoryChildren } from './file-tree-directory-children';
5
+
6
+ interface FileTreeProps {
7
+ currentEmailOpenSlug: string | undefined;
8
+ emailsDirectoryMetadata: EmailsDirectory;
9
+ }
10
+
11
+ export const FileTree = ({
12
+ currentEmailOpenSlug,
13
+ emailsDirectoryMetadata,
14
+ }: FileTreeProps) => {
15
+ return (
16
+ <div className="flex h-full w-full flex-col overflow-hidden lg:w-full lg:min-w-[14.5rem] lg:max-w-[14.5rem]">
17
+ <nav className="flex w-full flex-grow flex-col overflow-y-auto p-4 pr-0 pl-0">
18
+ <Collapsible.Root open>
19
+ <React.Suspense>
20
+ <FileTreeDirectoryChildren
21
+ currentEmailOpenSlug={currentEmailOpenSlug}
22
+ emailsDirectoryMetadata={emailsDirectoryMetadata}
23
+ isRoot
24
+ open
25
+ />
26
+ </React.Suspense>
27
+ </Collapsible.Root>
28
+ </nav>
29
+ </div>
30
+ );
31
+ };
@@ -0,0 +1,291 @@
1
+ import * as Collapsible from '@radix-ui/react-collapsible';
2
+ import { clsx } from 'clsx';
3
+ import { AnimatePresence, motion } from 'framer-motion';
4
+ import Lottie from 'lottie-react';
5
+ import * as React from 'react';
6
+ import {
7
+ type LinkCheckingResult,
8
+ checkLinks,
9
+ } from '../../actions/email-validation/check-links';
10
+ import animatedLoadIcon from '../../animated-icons-data/load.json';
11
+ import { cn } from '../../utils';
12
+ import { Button } from '../button';
13
+
14
+ const containerAnimation = {
15
+ hidden: { opacity: 0, y: 10 },
16
+ visible: {
17
+ opacity: 1,
18
+ y: 0,
19
+ transition: { duration: 0.6, ease: 'easeOut', staggerChildren: 0.1 },
20
+ },
21
+ };
22
+
23
+ const childAnimation = {
24
+ hidden: { opacity: 0, y: 5 },
25
+ visible: {
26
+ opacity: 1,
27
+ y: 0,
28
+ transition: { duration: 0.4, ease: 'easeOut' },
29
+ },
30
+ };
31
+
32
+ const statusStyles = {
33
+ error: 'text-red-600 hover:bg-red-600/10',
34
+ warning: 'text-yellow-300 hover:bg-yellow-400/10',
35
+ success: 'text-green-600 hover:bg-green-600/10',
36
+ };
37
+
38
+ interface LinkCheckerProps {
39
+ emailSlug: string;
40
+ emailMarkup: string;
41
+ }
42
+
43
+ type ResultStatus = 'error' | 'warning' | 'success';
44
+
45
+ interface CollapsibleTriggerProps {
46
+ count: number;
47
+ label: string;
48
+ variant: ResultStatus;
49
+ disabled?: boolean;
50
+ }
51
+
52
+ interface ResultSectionProps {
53
+ status: ResultStatus;
54
+ label: string;
55
+ results: LinkCheckingResult[];
56
+ open: boolean;
57
+ }
58
+
59
+ const CollapsibleTrigger = ({
60
+ count,
61
+ label,
62
+ variant,
63
+ disabled,
64
+ }: CollapsibleTriggerProps) => (
65
+ <Collapsible.Trigger
66
+ className={clsx(
67
+ 'group flex w-full items-center gap-1 rounded p-2 transition-colors duration-200 ease-[cubic-bezier(.36,.66,.6,1)]',
68
+ statusStyles[variant],
69
+ disabled && 'cursor-not-allowed opacity-70',
70
+ )}
71
+ disabled={disabled}
72
+ >
73
+ <span
74
+ className={clsx(
75
+ '-mt-[.125rem] transition-transform duration-200 ease-[cubic-bezier(.36,.66,.6,1)]',
76
+ 'rotate-0 group-data-[state=open]:rotate-90',
77
+ )}
78
+ >
79
+ <svg
80
+ fill="none"
81
+ height="15"
82
+ viewBox="0 0 15 15"
83
+ width="15"
84
+ xmlns="http://www.w3.org/2000/svg"
85
+ >
86
+ <path
87
+ clipRule="evenodd"
88
+ d="M6.1584 3.13508C6.35985 2.94621 6.67627 2.95642 6.86514 3.15788L10.6151 7.15788C10.7954 7.3502 10.7954 7.64949 10.6151 7.84182L6.86514 11.8418C6.67627 12.0433 6.35985 12.0535 6.1584 11.8646C5.95694 11.6757 5.94673 11.3593 6.1356 11.1579L9.565 7.49985L6.1356 3.84182C5.94673 3.64036 5.95694 3.32394 6.1584 3.13508Z"
89
+ fill="currentColor"
90
+ fillRule="evenodd"
91
+ />
92
+ </svg>
93
+ </span>
94
+ <div className="flex flex-1 items-center gap-1 font-bold text-[.625rem] uppercase tracking-wide">
95
+ <span>{label}</span>
96
+ <span>({count})</span>
97
+ </div>
98
+ </Collapsible.Trigger>
99
+ );
100
+
101
+ const ResultSection = ({
102
+ status,
103
+ label,
104
+ results,
105
+ open,
106
+ }: ResultSectionProps) => {
107
+ const isEmpty = results.length === 0;
108
+
109
+ return (
110
+ <Collapsible.Root className="group" defaultOpen={open && !isEmpty}>
111
+ <CollapsibleTrigger
112
+ count={results.length}
113
+ label={label}
114
+ variant={status}
115
+ disabled={isEmpty}
116
+ />
117
+ {!isEmpty && (
118
+ <Collapsible.Content>
119
+ <ol className="mt-2 mb-1 flex list-none flex-col gap-4">
120
+ {results.map((result, index) => (
121
+ <LinkResultView key={index} {...result} />
122
+ ))}
123
+ </ol>
124
+ </Collapsible.Content>
125
+ )}
126
+ </Collapsible.Root>
127
+ );
128
+ };
129
+
130
+ const LoadingButton = ({
131
+ loading,
132
+ onClick,
133
+ loadAnimation,
134
+ animatedLoadIcon,
135
+ children,
136
+ }: any) => {
137
+ React.useEffect(() => {
138
+ if (loading) {
139
+ loadAnimation.current?.play();
140
+ }
141
+ }, [loading, loadAnimation]);
142
+
143
+ return (
144
+ <Button
145
+ className="mt-2 mb-4 min-w-[5rem] transition-all disabled:border-transparent disabled:bg-slate-11"
146
+ disabled={loading}
147
+ onClick={onClick}
148
+ >
149
+ <div className="flex items-center justify-center gap-2">
150
+ <span
151
+ className={cn(
152
+ '-ml-7 opacity-0 transition-opacity duration-200',
153
+ loading && 'opacity-100',
154
+ )}
155
+ >
156
+ <Lottie
157
+ animationData={animatedLoadIcon}
158
+ autoPlay={false}
159
+ className="h-5 w-5"
160
+ loop={true}
161
+ lottieRef={loadAnimation}
162
+ />
163
+ </span>
164
+ <span>{children}</span>
165
+ </div>
166
+ </Button>
167
+ );
168
+ };
169
+
170
+ const LinkResultView = (props: LinkCheckingResult) => (
171
+ <AnimatePresence mode="wait">
172
+ <motion.li
173
+ animate="visible"
174
+ className="group/item relative w-full rounded-md p-2 pl-4 transition-colors duration-300 ease-out hover:bg-slate-5"
175
+ data-status={props.status}
176
+ initial="hidden"
177
+ layout
178
+ variants={containerAnimation}
179
+ >
180
+ <a
181
+ href={props.link}
182
+ target="_blank"
183
+ rel="noopener noreferrer"
184
+ className="w-full"
185
+ >
186
+ <motion.div
187
+ className="flex w-full items-center gap-2 text-xs group-data-[status=error]/item:text-red-400 group-data-[status=success]/item:text-green-400 group-data-[status=warning]/item:text-yellow-300"
188
+ variants={childAnimation}
189
+ >
190
+ <span className="block overflow-hidden truncate text-ellipsis whitespace-nowrap">
191
+ {props.link}
192
+ </span>
193
+ </motion.div>
194
+ <motion.div
195
+ className="mt-1 font-semibold text-[.625rem] uppercase"
196
+ variants={childAnimation}
197
+ >
198
+ {props.checks
199
+ .map((check) => {
200
+ if (check.type === 'syntax' && !check.passed)
201
+ return 'Invalid URL';
202
+ if (check.type === 'fetch_attempt')
203
+ return `${check.metadata.fetchStatusCode}`;
204
+ if (check.type === 'security')
205
+ return check.passed ? 'Secure' : 'Insecure';
206
+ return null;
207
+ })
208
+ .filter(Boolean)
209
+ .join(' - ')}
210
+ </motion.div>
211
+ </a>
212
+ </motion.li>
213
+ </AnimatePresence>
214
+ );
215
+
216
+ export const LinkChecker = ({ emailSlug, emailMarkup }: LinkCheckerProps) => {
217
+ const cacheKey = `link-checking-results-${emailSlug.replaceAll('/', '-')}`;
218
+ const cachedResults =
219
+ 'localStorage' in window ? window.localStorage.getItem(cacheKey) : null;
220
+
221
+ const [results, setResults] = React.useState<
222
+ LinkCheckingResult[] | undefined
223
+ >(cachedResults ? JSON.parse(cachedResults) : undefined);
224
+ const [sectionsOpen, setSectionsOpen] = React.useState(false);
225
+ const loadAnimation = React.useRef(null);
226
+ const [loading, setLoading] = React.useState(false);
227
+
228
+ const errorResults = React.useMemo(
229
+ () => results?.filter((r) => r.status === 'error') || [],
230
+ [results],
231
+ );
232
+ const warningResults = React.useMemo(
233
+ () => results?.filter((r) => r.status === 'warning') || [],
234
+ [results],
235
+ );
236
+ const successResults = React.useMemo(
237
+ () => results?.filter((r) => r.status === 'success') || [],
238
+ [results],
239
+ );
240
+
241
+ const handleRun = () => {
242
+ setLoading(true);
243
+ checkLinks(emailMarkup)
244
+ .then((newResults) => {
245
+ setResults(newResults);
246
+ setSectionsOpen(true);
247
+ localStorage.setItem(cacheKey, JSON.stringify(newResults));
248
+ })
249
+ .catch(console.error)
250
+ .finally(() => setLoading(false));
251
+ };
252
+
253
+ return (
254
+ <div className="mt-4 flex w-full flex-col gap-2 text-pretty">
255
+ {results ? (
256
+ <>
257
+ <ResultSection
258
+ label="Errors"
259
+ results={errorResults}
260
+ status="error"
261
+ open={sectionsOpen}
262
+ />
263
+ <ResultSection
264
+ label="Warnings"
265
+ results={warningResults}
266
+ status="warning"
267
+ open={sectionsOpen}
268
+ />
269
+ <ResultSection
270
+ label="Success"
271
+ results={successResults}
272
+ status="success"
273
+ open={sectionsOpen}
274
+ />
275
+ </>
276
+ ) : (
277
+ <span className="text-xs leading-relaxed">
278
+ Check if all links are valid and redirect to the correct pages.
279
+ </span>
280
+ )}
281
+ <LoadingButton
282
+ loading={loading}
283
+ onClick={handleRun}
284
+ loadAnimation={loadAnimation}
285
+ animatedLoadIcon={animatedLoadIcon}
286
+ >
287
+ {results ? 'Re-run' : 'Run'}
288
+ </LoadingButton>
289
+ </div>
290
+ );
291
+ };