From 55aed28a5c8ec9b71268d47598055997a827a4ba Mon Sep 17 00:00:00 2001 From: thedittya471 Date: Thu, 30 Jul 2026 19:21:37 +0530 Subject: [PATCH 1/7] feat(web): rebuild landing page with responsive paper-studio theme --- .npmrc | 0 apps/web/app/globals.css | 611 ++++- apps/web/app/layout.tsx | 14 +- apps/web/app/page.tsx | 907 ++++++- apps/web/components/BlendIcon.tsx | 159 ++ apps/web/components/ChartBarIcon.tsx | 127 + apps/web/components/CheckIcon.tsx | 122 + apps/web/components/CopyIcon.tsx | 127 + apps/web/components/Cubes.tsx | 382 +++ apps/web/components/FeedbackWidget.tsx | 255 ++ apps/web/components/Footer.tsx | 84 + apps/web/components/Navbar.tsx | 416 +++ apps/web/components/Noise.tsx | 87 + apps/web/components/RocketIcon.tsx | 139 + apps/web/components/ShareIcon.tsx | 136 + apps/web/components/ShieldUserIcon.tsx | 144 + apps/web/components/ZapIcon.tsx | 133 + .../components/analytics/HourlyHeatmap.tsx | 120 - .../components/landing/AnimatedCounter.tsx | 28 + apps/web/components/landing/ArticleTrio.tsx | 115 - .../components/landing/CanvasFlowMockup.tsx | 603 +++++ apps/web/components/landing/Comparison.tsx | 312 +++ apps/web/components/landing/CutDropOff.tsx | 136 - apps/web/components/landing/FooterSunset.tsx | 58 - apps/web/components/landing/HeroSunset.tsx | 101 - apps/web/components/landing/HowItWorks.tsx | 218 -- .../landing/IntellegentExecution.tsx | 161 ++ .../components/landing/LandingNavSunset.tsx | 53 - .../web/components/landing/NetworkSection.tsx | 37 - apps/web/components/landing/ScrollReveal.tsx | 45 + .../components/landing/StepIntoCanvasFlow.tsx | 44 - apps/web/components/landing/TrackInApp.tsx | 50 - .../components/landing/TypeWritterText.tsx | 49 + apps/web/components/ui/accordion.tsx | 64 - apps/web/components/ui/alert-dialog.tsx | 135 - apps/web/components/ui/alert.tsx | 60 - apps/web/components/ui/aspect-ratio.tsx | 9 - apps/web/components/ui/avatar.tsx | 41 - apps/web/components/ui/badge.tsx | 39 - apps/web/components/ui/breadcrumb.tsx | 102 - apps/web/components/ui/button-group.tsx | 78 - apps/web/components/ui/calendar.tsx | 180 -- apps/web/components/ui/card.tsx | 75 - apps/web/components/ui/carousel.tsx | 230 -- apps/web/components/ui/chart.tsx | 326 --- apps/web/components/ui/checkbox.tsx | 29 - apps/web/components/ui/collapsible.tsx | 21 - apps/web/components/ui/command.tsx | 161 -- apps/web/components/ui/context-menu.tsx | 224 -- apps/web/components/ui/dialog.tsx | 129 - apps/web/components/ui/drawer.tsx | 124 - apps/web/components/ui/dropdown-menu.tsx | 228 -- apps/web/components/ui/empty.tsx | 94 - apps/web/components/ui/field.tsx | 234 -- apps/web/components/ui/form.tsx | 152 -- apps/web/components/ui/hover-card.tsx | 38 - apps/web/components/ui/input-group.tsx | 158 -- apps/web/components/ui/input-otp.tsx | 70 - apps/web/components/ui/item.tsx | 172 -- apps/web/components/ui/kbd.tsx | 28 - apps/web/components/ui/label.tsx | 21 - apps/web/components/ui/menubar.tsx | 252 -- apps/web/components/ui/navigation-menu.tsx | 161 -- apps/web/components/ui/pagination.tsx | 106 - apps/web/components/ui/popover.tsx | 42 - apps/web/components/ui/progress.tsx | 28 - apps/web/components/ui/radio-group.tsx | 45 - apps/web/components/ui/resizable.tsx | 51 - apps/web/components/ui/scroll-area.tsx | 56 - apps/web/components/ui/select.tsx | 175 -- apps/web/components/ui/separator.tsx | 28 - apps/web/components/ui/sheet.tsx | 130 - apps/web/components/ui/sidebar.tsx | 694 ----- apps/web/components/ui/skeleton.tsx | 13 - apps/web/components/ui/slider.tsx | 58 - apps/web/components/ui/spinner.tsx | 16 - apps/web/components/ui/switch.tsx | 28 - apps/web/components/ui/table.tsx | 92 - apps/web/components/ui/tabs.tsx | 54 - apps/web/components/ui/toggle-group.tsx | 82 - apps/web/components/ui/toggle.tsx | 46 - apps/web/components/ui/tooltip.tsx | 57 - apps/web/hooks/use-mobile.ts | 19 - apps/web/hooks/use-toasts.ts | 197 ++ apps/web/hooks/useAnimatedCounter.ts | 23 + apps/web/hooks/useScrollReveal.ts | 19 + apps/web/next.config.js | 17 +- apps/web/package.json | 33 +- apps/web/trpc/server.ts | 11 - pnpm-lock.yaml | 2382 +++-------------- 90 files changed, 5564 insertions(+), 8546 deletions(-) delete mode 100644 .npmrc create mode 100644 apps/web/components/BlendIcon.tsx create mode 100644 apps/web/components/ChartBarIcon.tsx create mode 100644 apps/web/components/CheckIcon.tsx create mode 100644 apps/web/components/CopyIcon.tsx create mode 100644 apps/web/components/Cubes.tsx create mode 100644 apps/web/components/FeedbackWidget.tsx create mode 100644 apps/web/components/Footer.tsx create mode 100644 apps/web/components/Navbar.tsx create mode 100644 apps/web/components/Noise.tsx create mode 100644 apps/web/components/RocketIcon.tsx create mode 100644 apps/web/components/ShareIcon.tsx create mode 100644 apps/web/components/ShieldUserIcon.tsx create mode 100644 apps/web/components/ZapIcon.tsx delete mode 100644 apps/web/components/analytics/HourlyHeatmap.tsx create mode 100644 apps/web/components/landing/AnimatedCounter.tsx delete mode 100644 apps/web/components/landing/ArticleTrio.tsx create mode 100644 apps/web/components/landing/CanvasFlowMockup.tsx create mode 100644 apps/web/components/landing/Comparison.tsx delete mode 100644 apps/web/components/landing/CutDropOff.tsx delete mode 100644 apps/web/components/landing/FooterSunset.tsx delete mode 100644 apps/web/components/landing/HeroSunset.tsx delete mode 100644 apps/web/components/landing/HowItWorks.tsx create mode 100644 apps/web/components/landing/IntellegentExecution.tsx delete mode 100644 apps/web/components/landing/LandingNavSunset.tsx delete mode 100644 apps/web/components/landing/NetworkSection.tsx create mode 100644 apps/web/components/landing/ScrollReveal.tsx delete mode 100644 apps/web/components/landing/StepIntoCanvasFlow.tsx delete mode 100644 apps/web/components/landing/TrackInApp.tsx create mode 100644 apps/web/components/landing/TypeWritterText.tsx delete mode 100644 apps/web/components/ui/accordion.tsx delete mode 100644 apps/web/components/ui/alert-dialog.tsx delete mode 100644 apps/web/components/ui/alert.tsx delete mode 100644 apps/web/components/ui/aspect-ratio.tsx delete mode 100644 apps/web/components/ui/avatar.tsx delete mode 100644 apps/web/components/ui/badge.tsx delete mode 100644 apps/web/components/ui/breadcrumb.tsx delete mode 100644 apps/web/components/ui/button-group.tsx delete mode 100644 apps/web/components/ui/calendar.tsx delete mode 100644 apps/web/components/ui/card.tsx delete mode 100644 apps/web/components/ui/carousel.tsx delete mode 100644 apps/web/components/ui/chart.tsx delete mode 100644 apps/web/components/ui/checkbox.tsx delete mode 100644 apps/web/components/ui/collapsible.tsx delete mode 100644 apps/web/components/ui/command.tsx delete mode 100644 apps/web/components/ui/context-menu.tsx delete mode 100644 apps/web/components/ui/dialog.tsx delete mode 100644 apps/web/components/ui/drawer.tsx delete mode 100644 apps/web/components/ui/dropdown-menu.tsx delete mode 100644 apps/web/components/ui/empty.tsx delete mode 100644 apps/web/components/ui/field.tsx delete mode 100644 apps/web/components/ui/form.tsx delete mode 100644 apps/web/components/ui/hover-card.tsx delete mode 100644 apps/web/components/ui/input-group.tsx delete mode 100644 apps/web/components/ui/input-otp.tsx delete mode 100644 apps/web/components/ui/item.tsx delete mode 100644 apps/web/components/ui/kbd.tsx delete mode 100644 apps/web/components/ui/label.tsx delete mode 100644 apps/web/components/ui/menubar.tsx delete mode 100644 apps/web/components/ui/navigation-menu.tsx delete mode 100644 apps/web/components/ui/pagination.tsx delete mode 100644 apps/web/components/ui/popover.tsx delete mode 100644 apps/web/components/ui/progress.tsx delete mode 100644 apps/web/components/ui/radio-group.tsx delete mode 100644 apps/web/components/ui/resizable.tsx delete mode 100644 apps/web/components/ui/scroll-area.tsx delete mode 100644 apps/web/components/ui/select.tsx delete mode 100644 apps/web/components/ui/separator.tsx delete mode 100644 apps/web/components/ui/sheet.tsx delete mode 100644 apps/web/components/ui/sidebar.tsx delete mode 100644 apps/web/components/ui/skeleton.tsx delete mode 100644 apps/web/components/ui/slider.tsx delete mode 100644 apps/web/components/ui/spinner.tsx delete mode 100644 apps/web/components/ui/switch.tsx delete mode 100644 apps/web/components/ui/table.tsx delete mode 100644 apps/web/components/ui/tabs.tsx delete mode 100644 apps/web/components/ui/toggle-group.tsx delete mode 100644 apps/web/components/ui/toggle.tsx delete mode 100644 apps/web/components/ui/tooltip.tsx delete mode 100644 apps/web/hooks/use-mobile.ts create mode 100644 apps/web/hooks/use-toasts.ts create mode 100644 apps/web/hooks/useAnimatedCounter.ts create mode 100644 apps/web/hooks/useScrollReveal.ts delete mode 100644 apps/web/trpc/server.ts diff --git a/.npmrc b/.npmrc deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 9f680c7..9670220 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -27,19 +27,6 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); } :root { @@ -62,23 +49,8 @@ --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); } - - @layer base { * { @apply border-border outline-ring/50; @@ -111,9 +83,6 @@ --cf-line-strong: rgba(22, 19, 17, 0.14); --cf-orange: #f66f00; --cf-orange-hover: #e26300; - - /* hero dark warm photo-feel */ - --cf-hero-bg: #2a2620; } /* Warm text selection */ @@ -145,66 +114,554 @@ font-weight: 500; } -/* Full-page subtle blueprint grid (4 vertical guides + horizontal rules per section). */ -.cf-page-grid { +/* ============================================================ + CanvasFlow — "paper studio" landing system + ------------------------------------------------------------ + Design tokens + component classes consumed by app/page.tsx and the + landing mockups in components/landing/. Those files reference + `hex-*` class names and `--hex-*` / `--c-*` custom properties, so + they are all declared here. + ============================================================ */ + +:root { + /* Ink ramp */ + --hex-ink: #1a1d29; + --hex-ink-soft: #5b6070; + --hex-ink-muted: #8f95a3; + + /* Two rules, two jobs. + + `--hex-line-strong` is the structural edge — the outline of a button, + a field, a card, a crop mark. It is near-black so those shapes read + as drawn boxes rather than as soft panels. + + `--hex-line` is the internal hairline: row dividers, sub-panel + splits, the fine grid inside the mockups. It stays light on purpose. + Promoting it to black as well would turn every mockup into a dense + black lattice and bury the type inside them. */ + --hex-line: rgba(26, 29, 41, 0.16); + --hex-line-strong: #1a1d29; + + /* Radius for the landing chrome. Square by default — see the + `.hex-theme` radius reset further down. */ + --hex-radius: 0px; + + /* Paper surfaces. `--hex-paper` is the pale warm page stock and + `--hex-nav` the translucent band that floats on top of it. + + The stock is light on purpose. Each of the page's three backgrounds + is an image layer painted *normally* over it, so the sheet needs + headroom for them to read — see .hex-hero-paper / .hex-section-paper. + `--hex-surface` is the near-white fill for insets inside cards. + + The nav sits a shade *below* the paper rather than above it: a band + lighter than the sheet reads as a bright strip across the top, so + this is a duller greige that settles into the page instead. Alpha is + higher than the old near-white value to keep text contrast while the + tone comes down. */ + --hex-paper: #f5f3ee; + --hex-nav: rgba(231, 229, 221, 0.82); + --hex-surface: #fdfdfb; + + /* Bone: a warm off-white for outlined buttons. Kept a touch yellower + and lighter than the paper stock so a button reads as a panel laid on + the sheet, and clearly lighter than the dull nav band it sits in. + Pure white would be a cold hole in a page that has no pure white + anywhere else in its chrome. */ + --hex-bone: #faf8f1; + + /* Data-viz palette (multi-line chart, scatter, stacked bars) */ + --c-orange: #e0834a; + --c-yellow: #e3b23c; + --c-teal: #3aa793; + --c-blue: #4a6bc4; + --c-purple: #6c5ce7; + --c-lavender: #a79ae4; + --c-red: #dd6459; +} + +@layer components { + /* ---- Surfaces -------------------------------------------------- */ + .hex-card { + background: #fff; + border: 1px solid var(--hex-line-strong); + border-radius: var(--hex-radius); + /* A hard offset rather than a blur. Once the card has a black outline + a diffuse shadow reads as a smudge behind a drawn edge; an opaque + step keeps the whole shape crisp and sits better on the textured + paper. */ + box-shadow: 5px 5px 0 0 rgba(26, 29, 41, 0.08); + } + + /* Border-colour helpers. These are paired with Tailwind width + utilities (`border`, `border-b`, `border-r`, ...) in the mockups. */ + .hex-line-soft { + border-color: var(--hex-line); + } + .hex-line-strong { + border-color: var(--hex-line-strong); + } + + .hex-divider { + height: 1px; + width: 100%; + background: var(--hex-line); + } + + /* ---- Type ------------------------------------------------------ */ + .hex-mono { + font-family: var(--font-mono-cf), ui-monospace, SFMono-Regular, Menlo, monospace; + font-feature-settings: "tnum" 1; + } + + /* ---- Dashboard chrome ------------------------------------------ */ + .hex-kpi { + border: 1px solid var(--hex-line-strong); + border-radius: var(--hex-radius); + padding: 12px 14px; + background: var(--hex-surface); + } + + .hex-select-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--hex-ink-muted); + margin-bottom: 4px; + } + .hex-select { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + height: 30px; + padding: 0 10px; + font-size: 11px; + background: #fff; + border: 1px solid var(--hex-line-strong); + border-radius: var(--hex-radius); + } + + .hex-row-hover { + transition: background 0.15s ease; + } + .hex-row-hover:hover { + background: rgba(26, 29, 41, 0.02); + } + + /* Fine blueprint grid used behind the builder canvas. + The background-size is essential: without it each gradient stretches + to fill the element and paints a single line down its left edge and + across its top instead of a repeating grid. */ + .hex-grid-fine { + background-image: + linear-gradient(to right, var(--hex-ink) 1px, transparent 1px), + linear-gradient(to bottom, var(--hex-ink) 1px, transparent 1px); + background-size: 24px 24px; + } + + /* Slim scrollbars inside the mockups so they read as real UI. */ + .custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(26, 29, 41, 0.16) transparent; + } + .custom-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; + } + .custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + .custom-scrollbar::-webkit-scrollbar-thumb { + background: rgba(26, 29, 41, 0.16); + border-radius: 999px; + } +} + +/* ---- Mockup motion ---------------------------------------------- */ + +/* Line-chart stroke draw-on. `--len` is supplied inline per path. */ +@keyframes hex-draw { + from { + stroke-dashoffset: var(--len, 600); + } + to { + stroke-dashoffset: 0; + } +} +.hex-line-path { + stroke-dasharray: var(--len, 600); + animation: hex-draw 1.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; +} + +/* Scatter dots pop in, staggered by inline animation-delay. */ +@keyframes hex-pop { + from { + transform: scale(0); + opacity: 0; + } + to { + transform: scale(1); + opacity: 0.78; + } +} +.hex-dot { + transform-box: fill-box; + transform-origin: center; + animation: hex-pop 0.45s cubic-bezier(0.22, 1, 0.36, 1) backwards; +} + +/* Stacked bars grow from the left. */ +@keyframes hex-grow-x { + from { + transform: scaleX(0); + } + to { + transform: scaleX(1); + } +} +.hex-bar-x { + transform-origin: left center; + animation: hex-grow-x 0.6s cubic-bezier(0.22, 1, 0.36, 1) backwards; +} + +/* Dashed connector flow in the routing graph (used via + `animate-[dash_20s_linear_infinite]`). */ +@keyframes dash { + to { + stroke-dashoffset: -200; + } +} + +/* CRT scanline sweep for the dark comparison matrix. */ +@keyframes scanlines { + from { + background-position: 0 0; + } + to { + background-position: 0 100%; + } +} +.animate-scanlines { + animation: scanlines 8s linear infinite; +} + +@media (prefers-reduced-motion: reduce) { + .hex-line-path, + .hex-dot, + .hex-bar-x, + .animate-scanlines, + /* Tailwind's own looping utilities aren't covered by its reset, so the + live-feed indicators get listed here explicitly. */ + .animate-ping, + .animate-pulse { + animation: none !important; + } + .hex-line-path { + stroke-dasharray: none; + } +} + +/* Small mono figure caption ("FIG.01"). */ +.hex-fig { + font-family: var(--font-mono-cf), ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 9px; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--hex-ink-muted); +} + +/* ============================================================ + Landing page chrome referenced directly by app/page.tsx + (hex-theme / hex-paper / hex-hero-paper / hex-section-paper / + hex-corner / hex-vignette, the button + link recipes, and the FAQ + disclosure rows). + ============================================================ */ + +:root { + /* Ruler/scale strips: border colour and the diagonal hatch stroke. */ + --pattern: rgba(26, 29, 41, 0.18); + --pattern-hatch: #d4d4d4; +} + +.hex-theme { + color: var(--hex-ink); + font-family: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; +} + +/* ---- Boxy landing chrome ----------------------------------------- + Squares off the corner-radius utilities inside the landing theme in + one place, instead of rewriting ~47 `rounded-*` call sites across + page.tsx, Navbar.tsx and the mockups — and, more importantly, so the + rule can't drift out of step as those files change. + + `rounded-full` is deliberately absent from this list. Avatars, status + dots, radio marks and the toggle pills inside the mockups are meant to + be circular; flattening those would read as a bug, not a style. + + The selectors are scoped under `.hex-theme` rather than global so the + dashboard, auth and form-filling screens keep their own rounding. Two + class selectors also outrank Tailwind's single-class utility, which is + what lets this win without `!important`. */ +.hex-theme .rounded, +.hex-theme .rounded-sm, +.hex-theme .rounded-md, +.hex-theme .rounded-lg, +.hex-theme .rounded-xl, +.hex-theme .rounded-2xl, +.hex-theme .rounded-3xl, +.hex-theme .rounded-4xl, +.hex-theme .rounded-\[1px\], +.hex-theme .rounded-\[4px\] { + border-radius: var(--hex-radius); +} + +/* ---- The page stock ---------------------------------------------- + A pale warm sheet with a fine tooth. The tooth is two dot grids at + 3px and 7px, offset from one another so the beat between them reads + as paper fibre rather than as a visible dot pattern. + + Nothing here is sized to the document. This element is the page root, + so its height is the whole scroll length — any layer measured in `%` + vertically would stretch across every section at once and lay a + top-to-bottom tonal ramp over the page, tinting the hero one shade and + the footer another. The page's three backgrounds are section-scoped + layers instead (.hex-hero-paper, .hex-section-paper), which is what + keeps them independently tunable. */ +.hex-paper { + background-color: var(--hex-paper); + background-image: + radial-gradient(rgba(26, 29, 41, 0.025) 1px, transparent 1px), + radial-gradient(rgba(26, 29, 41, 0.018) 1px, transparent 1px); + background-size: + 3px 3px, + 7px 7px; + background-position: + 0 0, + 1px 2px; +} + +/* ---- The three section backgrounds ------------------------------ + The landing page runs three distinct sheets, each scoped to the + sections that use it rather than painted page-wide: + + 1 · Hero — .hex-hero-paper, a tiled crack network + 2 · Mid page — nothing; the bare stock above + 3 · Analytics — .hex-section-paper, one large plate + + Both image layers paint at normal blend, *not* multiply. That is the + whole mechanism: the source art has a white ground, so painting it + normally lays down its dark structure and lifts the cream a step + toward white at the same time. The hero ends up the lightest of the + three and the bare mid-page sections the deepest, which is the tonal + rhythm the design is after. Multiplying would drop the white ground + out — white is the identity for multiply — and every section would + collapse back to the same tone. */ + +/* 1 · Hero. Tiled small enough that the cells read at page scale, and + capped in vw so the tile doesn't dominate a narrow viewport. */ +.hex-hero-paper { position: absolute; inset: 0; + z-index: 0; pointer-events: none; - z-index: 1; - background-image: - linear-gradient(to right, var(--cf-line) 1px, transparent 1px); - background-size: 25% 100%; - background-position: 25% 0; + opacity: 0.3; + background-image: url("https://ik.imagekit.io/yatharth/ChatGPT%20Image%20Apr%2030,%202026,%2007_52_59%20PM.png"); + background-size: min(420px, 45vw) auto; + background-repeat: repeat; } -/* Subtle grain texture (paper feel) */ -.cf-grain { - background-image: radial-gradient(rgba(22, 19, 17, 0.05) 1px, transparent 1px); - background-size: 4px 4px; +/* 3 · Analytics. A single plate scaled to cover, so the section reads + as one continuous sheet rather than a repeat. Held much fainter than + the hero tile because it carries far more structure. */ +.hex-section-paper { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + opacity: 0.1; + background-image: url("https://ik.imagekit.io/yatharth/BG-SECTION-.png"); + background-size: cover; + background-position: center; + background-repeat: no-repeat; } -/* Photographic-feeling dark warm hero — looks like a backlit golden-hour scene, - no external image needed. Three layered radial+linear gradients + grain. */ -.cf-hero-photo { - background-color: #1c1814; - background-image: - radial-gradient(60% 80% at 80% 90%, rgba(255, 138, 52, 0.55), transparent 65%), - radial-gradient(50% 60% at 15% 8%, rgba(28, 22, 18, 0.85), transparent 70%), - radial-gradient(35% 40% at 70% 25%, rgba(168, 110, 62, 0.45), transparent 70%), - linear-gradient(180deg, #2a2620 0%, #3a2d20 45%, #6a4221 100%); +@media (prefers-reduced-transparency: reduce) { + /* Plain stock for anyone who has asked for less visual noise. */ + .hex-paper { + background-image: none; + } + .hex-hero-paper, + .hex-section-paper { + display: none; + } } -/* Vivid solid orange tile (used for the right-hand "energy panel" in hero). */ -.cf-orange-tile { - background-color: #f66f00; - background-image: - radial-gradient(120% 90% at 50% 42%, rgba(255, 214, 138, 0.35), transparent 60%), - repeating-linear-gradient( - 108deg, - #ffa84a 0px, - #f87c14 70px, - #f66f00 130px, - #d85a00 190px, - #f66f00 250px, - #ffa84a 320px - ); +/* Crop-mark corner bracket. Callers zero out two sides inline to pick + which corner it represents. */ +.hex-corner { + position: absolute; + width: 18px; + height: 18px; + border: 1px solid var(--hex-line-strong); + pointer-events: none; + z-index: 2; } -@media (prefers-reduced-motion: reduce) { - .cf-float, - .cf-spin-slow { - animation: none !important; +/* Worn-paper vignette. Two radials that shade only the top and bottom + edges of a section, so a long band of stock doesn't read as flat. + Deliberately darkening rather than tinting with the paper colour: a + paper-coloured wash would cover the tooth and leave a visible dead + ring at the section edges. */ +.hex-vignette::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(120% 60% at 50% 0%, transparent 60%, rgba(26, 29, 41, 0.04) 100%), + radial-gradient(120% 60% at 50% 100%, transparent 60%, rgba(26, 29, 41, 0.04) 100%); +} + +@layer components { + /* ---- Buttons & links ------------------------------------------- */ + .hex-btn-primary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: var(--hex-radius); + background: var(--hex-ink); + color: #fff; + font-weight: 500; + border: 1px solid var(--hex-ink); + transition: + opacity 0.2s ease, + transform 0.2s ease; + cursor: pointer; + } + .hex-btn-primary:hover { + opacity: 0.9; + } + .hex-btn-primary:active { + transform: translateY(1px); + } + + .hex-btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 18px; + border-radius: var(--hex-radius); + background: var(--hex-bone); + color: var(--hex-ink); + font-size: 14px; + /* 600, not 500. On a transparent ground the animated grain sits behind + the glyphs as well as around them, and a medium weight loses + definition against it. */ + font-weight: 600; + /* 1.5px reads as a drawn edge and roughly matches the stem weight of + the label beside it. A hairline looks incidental at this scale, and + a full 2px starts to feel like a frame rather than an outline. */ + border: 1.5px solid var(--hex-line-strong); + transition: + background 0.2s ease, + color 0.2s ease; + cursor: pointer; + } + .hex-btn-ghost:hover { + background: var(--hex-ink); + color: #fff; + } + + .hex-link { + color: var(--hex-ink); + font-weight: 500; + text-decoration: underline; + text-decoration-color: var(--hex-line-strong); + text-underline-offset: 5px; + transition: text-decoration-color 0.2s ease; + } + .hex-link:hover { + text-decoration-color: var(--hex-ink); } } -@keyframes cf-float { - 0%, 100% { transform: translateY(0px); } - 50% { transform: translateY(-10px); } +/* ---- FAQ disclosure rows ----------------------------------------- */ +.hex-faq-row > summary { + display: flex; + align-items: center; + justify-content: space-between; + /* Tighter on phones. A 24px gap plus 22px of vertical padding costs a + lot of width and height when six of these stack on a 375px screen. */ + gap: 14px; + padding: 16px 2px; + cursor: pointer; + list-style: none; } -.cf-float { animation: cf-float 7s ease-in-out infinite; } -@keyframes cf-spin-slow { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } +@media (min-width: 640px) { + .hex-faq-row > summary { + gap: 24px; + padding: 22px 4px; + } +} +/* Hide the native disclosure marker in both engines. */ +.hex-faq-row > summary::-webkit-details-marker { + display: none; +} +.hex-faq-row > summary::marker { + content: ""; +} +.hex-faq-row > summary:focus-visible { + outline: 2px solid var(--hex-ink); + outline-offset: 2px; + border-radius: 4px; +} + +.hex-faq-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + flex: none; + border-radius: var(--hex-radius); + border: 1px solid var(--hex-line-strong); + font-size: 13px; + line-height: 1; + color: var(--hex-ink-soft); + transition: + transform 0.25s ease, + background 0.2s ease, + color 0.2s ease; +} +.hex-faq-row[open] .hex-faq-icon { + transform: rotate(45deg); + background: var(--hex-ink); + color: #fff; + border-color: var(--hex-ink); +} + +.hex-faq-body { + padding: 0 2px 18px; + max-width: 62ch; + font-size: 14px; + line-height: 1.65; + color: var(--hex-ink-soft); +} + +@media (min-width: 640px) { + .hex-faq-body { + padding: 0 4px 24px; + font-size: 15px; + } +} + +@media (prefers-reduced-motion: reduce) { + .hex-faq-icon { + transition: none; + } } -.cf-spin-slow { animation: cf-spin-slow 60s linear infinite; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 231366c..ce8d6c4 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import localFont from "next/font/local"; -import { Fraunces, Inter, JetBrains_Mono } from "next/font/google"; +import { Fraunces, Instrument_Serif, Inter, JetBrains_Mono } from "next/font/google"; import "./globals.css"; import { GlobalProviders } from "~/providers/global"; @@ -34,6 +34,16 @@ const jbMono = JetBrains_Mono({ display: "swap", }); +// Editorial italic accent inside the landing headlines +// ("…for when you want to click around.", "No ceremony."). +const instrumentSerif = Instrument_Serif({ + subsets: ["latin"], + weight: "400", + style: ["normal", "italic"], + variable: "--font-instrument-serif", + display: "swap", +}); + export const metadata: Metadata = { title: "CanvasFlow", description: "Form Builder", @@ -50,7 +60,7 @@ export default function RootLayout({ return ( {children} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 2f71fd6..a7b7b3d 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,32 +1,887 @@ "use client"; -import React from "react"; -import { authClient } from "~/lib/auth-client"; -import { LandingNavSunset } from "~/components/landing/LandingNavSunset"; -import { HeroSunset } from "~/components/landing/HeroSunset"; -import { HowItWorks } from "~/components/landing/HowItWorks"; -import { CutDropOff } from "~/components/landing/CutDropOff"; -import { TrackInApp } from "~/components/landing/TrackInApp"; -import { ArticleTrio } from "~/components/landing/ArticleTrio"; -import { NetworkSection } from "~/components/landing/NetworkSection"; -import { StepIntoCanvasFlow } from "~/components/landing/StepIntoCanvasFlow"; -import { FooterSunset } from "~/components/landing/FooterSunset"; - -export default function LandingPage() { - const { data: session } = authClient.useSession(); - const isLoggedIn = !!session; +import React, { useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { motion } from "motion/react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import Footer from "~/components/Footer"; +import Navbar from "~/components/Navbar"; +import Noise from "~/components/Noise"; +import { + GeoGlyph, + FormBuilderMock, + ResponseFeedMock, + CanvasEditorMock, + AnalyticsMock, +} from "~/components/landing/CanvasFlowMockup"; +import { useGetLoggedInUserInfo } from "~/hooks/api/auth"; +import { useCreateForm } from "~/hooks/api/form"; +import { cn } from "~/lib/utils"; + +/* ── Ruler strips ──────────────────────────────────────────────────── */ + +const HATCH = + "bg-[repeating-linear-gradient(315deg,_var(--pattern-hatch)_0px,_var(--pattern-hatch)_1px,_transparent_1px,_transparent_10px)] bg-[length:14px_14px]"; + +const HorizontalScale = ({ className }: { className?: string }) => ( +
+); + +const VerticalScale = ({ className }: { className?: string }) => ( +
+); + +/* ── Copy ──────────────────────────────────────────────────────────── */ + +const STARTERS = ["Customer feedback", "Event registration", "Job application"]; + +const FIELD_TYPES = [ + "Short text", + "Long text", + "Email", + "Phone", + "URL", + "Number", + "Dropdown", + "Checkboxes", + "Rating", + "Toggle", + "Date", + "Time", +]; + +const FAQS = [ + { + q: "How is CanvasFlow different from Google Forms?", + a: "Respondents answer one question at a time with a progress bar, so long forms don't feel long. On your side you get views as well as responses, completion rate, drop-off per question, and device split — not just a spreadsheet of answers.", + }, + { + q: "Do my respondents need an account?", + a: "No. Once you publish a form, anyone with the link can fill it in. CanvasFlow keeps one response per visitor and ignores duplicate submits, so a refresh or a double-click won't skew your numbers.", + }, + { + q: "Which field types can I use?", + a: `${FIELD_TYPES.slice(0, -1).join(", ")}, and ${FIELD_TYPES.at(-1)?.toLowerCase()}. Each field can be marked required, given a placeholder and a description, and dragged into any order. Email and URL fields are format-checked as people type.`, + }, + { + q: "How do I stop collecting responses?", + a: "Close the form with one toggle, set an expiry date, or cap the total number of submissions. You can also unpublish it entirely, which puts it back into draft and takes the public link offline.", + }, + { + q: "Can I export my responses?", + a: "Yes. Every submission lands in a paginated table you can read through, and you can download the whole set as CSV whenever you like.", + }, + { + q: "Can my team help build a form?", + a: "Invite teammates as collaborators and give each one access per area — the builder, the analytics, the responses, or the settings. You can change someone's role later, remove them, or transfer ownership outright.", + }, +]; + +/** Turn a form title into a URL-safe, reasonably unique slug. */ +const slugify = (value: string) => { + const base = value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); + const suffix = Math.random().toString(36).slice(2, 8); + return `${base || "form"}-${suffix}`; +}; + +/* ── Page ──────────────────────────────────────────────────────────── */ + +const Index = () => { + const [title, setTitle] = useState(""); + const { userInfo: user } = useGetLoggedInUserInfo(); + const { createFormAsync, isPending } = useCreateForm(); + const router = useRouter(); + + const handleCreate = async () => { + if (!user) { + router.push("/signUp"); + return; + } + if (!title.trim() || isPending) return; + + const formTitle = title.trim().slice(0, 150); + const toastId = toast.loading("Creating your form...", { + description: "Opening a fresh canvas.", + }); + + try { + const created = await createFormAsync({ title: formTitle, slug: slugify(formTitle) }); + + toast.success("Form created", { + id: toastId, + description: `Opening "${formTitle}" in the builder.`, + }); + + router.push(`/dashboard/sketches/${created.id}`); + } catch (error) { + console.error(error); + toast.error("Could not create the form", { + id: toastId, + description: + error instanceof Error ? error.message : "Something went wrong. Please try again.", + }); + } + }; return ( -
- - - - - - - - - +
+ {/* Animated film grain over the whole viewport. Refreshed every 4th + frame rather than every 2nd to cut the per-frame pixel work in + half without the grain looking static. */} + + + {/* Ruled page margins. These are overlays, not layout, so on narrow + screens they would sit on top of the copy rather than beside it — + the gutter that makes room for them only exists from md up. */} +
+ + +
+ + + + {/* ── Hero ───────────────────────────────────────────────────── */} + {/* The bottom rule is the seam where background 1 hands off to + background 2. Every change of sheet on the page gets one, so the + switch reads as a deliberate edge rather than a gradient. */} +
+ {/* Background 1 of 3: tiled crack network, the lightest sheet. */} +
+ {/* Crop marks pull in on small screens so they don't collide with + the body text, and are hidden outright below sm where there is + no spare gutter for them to sit in. */} +
+
+ +
+
+
+ + {/* Four steps rather than two. The headline is the widest + thing on the page, and 64px only fits from roughly + 640px up — below that it overflows a 375px screen. */} +

+ Forms,
+ + thoughtfully + + + + +
+ built for teams. +

+ +

+ Twelve field types, one question at a time for whoever fills it in, and real + numbers on the other side. Publish with a link, close it when you’re done. +

+ +
+ {/* Field and button share one drawn box: the wrapper + carries the black outline and the submit sits flush + inside it, so the pair reads as a single control. */} +
+ + setTitle(e.target.value)} + placeholder="Name your form..." + className="w-full border-none bg-transparent px-4 py-3 text-[15px] placeholder:text-muted-foreground/60 focus:outline-none sm:flex-1" + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void handleCreate(); + } + }} + /> + +
+ +
+
+ + Start with: + + {STARTERS.map((s) => ( + + ))} +
+
+
+
+
+ + + CanvasFlow form builder interface + +
+ +
+
+
+ +
+ {/* The wide mock takes the larger column and the upward + offset, so the pair reads as staggered rather than as + two cards on a shared baseline. */} +
+ +
+
+
+
+
+ + {/* ── FIG.01 · The canvas ────────────────────────────────────── */} + + A canvas that
+ stays out of the way. + + } + body="Sane defaults. No fifty-tab settings panels. Drop in a field, write the question, mark it required — your form is already publishable, accessible, and fast on mobile." + cta="Open the builder" + mock={} + reverse + /> + + {/* ── FIG.02 · Dashboards ────────────────────────────────────── */} +
+ {/* Background 3 of 3: one large plate scaled to cover, plus the + worn-paper vignette from `hex-vignette` on the section. */} +
+ +
+
+
+
+ + FIG.02 +
+

+ Beautiful dashboards,{" "} + + for when you want to click around. + +

+

+ Views and responses, completion rate, the hour and the day people actually reply, + and which question they gave up on. Your form becomes a real dashboard the second + answers land — no exports, no spreadsheets. +

+ + Explore analytics → + +
+
+ +
+
+
+ +
+ + {/* ── FIG.03 · Sharing & access ──────────────────────────────── */} +
+ {/* Background 2 of 3: the bare page stock, no overlay. */} +
+
+
+
+ + FIG.03 +
+

+ One link.
+ + You decide when it closes. + +

+

+ Publish and share the link, or hand over a QR code. Close the form with a toggle, + give it an expiry date, or cap the number of submissions. Every response is kept to + one per visitor. +

+ + Your forms → + +
+
+ +
+
+
+
+ + {/* ── Three steps ────────────────────────────────────────────── */} +
+ {/* Background 2 of 3: the bare page stock, no overlay. */} +
+
+

+ Three steps.{" "} + + No ceremony. + +

+
+ +
+ } + /> + } + /> + } + /> +
+
+
+ + {/* ── Collaboration ──────────────────────────────────────────── */} +
+
+
+
+
+ + FIG.04 +
+

+ Bring the team,{" "} + + not the whole company. + +

+
+
+ {[ + { + h: "Access per area", + d: "Give a collaborator the builder, the analytics, the responses, or the settings — separately.", + }, + { + h: "Roles you can change", + d: "Promote, demote, or remove someone at any time without rebuilding the form.", + }, + { + h: "Hand over ownership", + d: "Transfer a form to someone else outright when it stops being yours to run.", + }, + { + h: "Sign in your way", + d: "Email and password, or Google and GitHub if you'd rather skip another password.", + }, + ].map((item) => ( +
+

{item.h}

+

+ {item.d} +

+
+ ))} +
+
+
+
+ + {/* ── FAQ ────────────────────────────────────────────────────── */} +
+ +
+
+

+ Questions,{" "} + + answered. + +

+

+ The short version of what teams ask us most. Want the long one? +

+ + Talk to the team → + +
+
+ {FAQS.map((f, i) => ( +
+ + + + 0{i + 1} + + {f.q} + + + + +
+ {f.a} +
+
+ ))} +
+
+
+ +
); -} +}; + +/* ── Sharing & access mock ─────────────────────────────────────────── */ + +const ShareAccessMock = () => ( +
+
+ Share · Customer Feedback + + published + +
+ +
+ {/* Public link */} +
+
Public link
+
+
+ canvasflow.app/forms/customer-feedback +
+ +
+
+ + {/* QR + access controls. The QR column is a fixed 104px, which leaves + too little for the toggle rows beside it on a phone — below sm the + two stack and the QR is capped so it doesn't blow up full-width. */} +
+
+
QR code
+
+ {/* Deterministic pattern so server and client markup match. */} + {Array.from({ length: 49 }).map((_, i) => ( + + ))} +
+
+ +
+
Access
+
+ {[ + { l: "Accepting responses", v: "On", on: true }, + { l: "Expires", v: "31 Aug 2026", on: true }, + { l: "Max submissions", v: "500", on: true }, + { l: "One response per visitor", v: "On", on: true }, + ].map((row) => ( +
+ + {row.l} + + + {row.v} + + + + +
+ ))} +
+
+
+ +
+ 328 / 500 submissions + closes in 32 days +
+
+
+); + +/* ── Step cards ────────────────────────────────────────────────────── */ + +const FeatureStepCard = ({ + n, + t, + d, + illustration, +}: { + n: string; + t: string; + d: string; + illustration: React.ReactNode; +}) => ( + +
+ +
+
+ {/* The illustrations are drawn at a fixed 224x160. Scaling the whole + group down on small screens keeps their internal proportions + intact, which reflowing their parts would not. */} +
+ {illustration} +
+
+
+
+
+
+ Step / {n} +
+
+

{t}

+

+ {d} +

+ +
+ + Learn More + +
+ + + + +
+
+
+ +); + +/* ── Step illustrations ────────────────────────────────────────────── */ + +const BuildIllustration = () => ( +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +const ShareIllustration = () => ( +
+ + + + +
+
+
+
+
+
+
+
+
+
+
+ COPY LINK +
+
+
+
+
+
+
+); + +const SignalIllustration = () => ( +
+
+
+
+
+ + + + + + + + + + + + + + +
+
+ LIVE +
+
+ 87% +
+
+); + +/* ── Alternating feature block ─────────────────────────────────────── */ + +const FeatureBlock = ({ + id, + glyph, + title, + body, + cta, + mock, + reverse, +}: { + id?: string; + glyph: string; + title: React.ReactNode; + body: string; + cta: string; + mock: React.ReactNode; + reverse?: boolean; +}) => { + const { userInfo: user } = useGetLoggedInUserInfo(); + const router = useRouter(); + + return ( +
+
+
*:first-child]:order-2" + : "lg:grid-cols-[1fr_1.8fr]", + )} + > +
+
+ + FIG.{glyph} +
+

{title}

+

+ {body} +

+
+ +
+
+ +
+
{mock}
+
+
+
+
+ ); +}; + +export default Index; diff --git a/apps/web/components/BlendIcon.tsx b/apps/web/components/BlendIcon.tsx new file mode 100644 index 0000000..3340bfd --- /dev/null +++ b/apps/web/components/BlendIcon.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { cn } from "~/lib/utils"; +import type { HTMLMotionProps, Variants } from "motion/react"; +import { motion, useAnimation, useReducedMotion } from "motion/react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; + +export interface BlendHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface BlendProps extends HTMLMotionProps<"div"> { + size?: number; + duration?: number; + isAnimated?: boolean; +} + +const BlendIcon = forwardRef( + ( + { + onMouseEnter, + onMouseLeave, + className, + size = 24, + duration = 1, + isAnimated = true, + ...props + }, + ref, + ) => { + const controls = useAnimation(); + const reduced = useReducedMotion(); + const isControlled = useRef(false); + + useImperativeHandle(ref, () => { + isControlled.current = true; + return { + startAnimation: () => + reduced ? controls.start("normal") : controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleEnter = useCallback( + (e?: React.MouseEvent) => { + if (!isAnimated || reduced) return; + if (!isControlled.current) controls.start("animate"); + else onMouseEnter?.(e as any); + }, + [controls, reduced, isAnimated, onMouseEnter], + ); + + const handleLeave = useCallback( + (e?: React.MouseEvent) => { + if (!isControlled.current) controls.start("normal"); + else onMouseLeave?.(e as any); + }, + [controls, onMouseLeave], + ); + + const groupVariants: Variants = { + normal: { rotate: 0, scale: 1 }, + animate: { + rotate: [0, -1.5, 1.5, 0], + scale: [1, 1.02, 1], + transition: { + duration: 0.7 * duration, + ease: "easeInOut" as const, + }, + }, + }; + + const leftCircle: Variants = { + normal: { strokeDashoffset: 0, opacity: 1, x: 0 }, + animate: { + strokeDashoffset: [48, 0], + opacity: [0.45, 1], + transition: { duration: 0.6 * duration, ease: "easeInOut" }, + }, + }; + + const rightCircle: Variants = { + normal: { strokeDashoffset: 0, opacity: 1, x: 0 }, + animate: { + strokeDashoffset: [48, 0], + opacity: [0.45, 1], + transition: { + duration: 0.6 * duration, + ease: "easeInOut", + delay: 0.09, + }, + }, + }; + + const overlapPulse: Variants = { + normal: { scale: 1, opacity: 1 }, + animate: { + scale: [1, 1.06, 1], + opacity: [0.9, 1, 1], + transition: { + duration: 0.5 * duration, + ease: "easeInOut", + delay: 0.25, + }, + }, + }; + + return ( + + + + + + + + + + + ); + }, +); + +BlendIcon.displayName = "BlendIcon"; +export { BlendIcon }; \ No newline at end of file diff --git a/apps/web/components/ChartBarIcon.tsx b/apps/web/components/ChartBarIcon.tsx new file mode 100644 index 0000000..ab6f02c --- /dev/null +++ b/apps/web/components/ChartBarIcon.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { cn } from "~/lib/utils"; +import type { HTMLMotionProps, Variants } from "motion/react"; +import { motion, useAnimation, useReducedMotion } from "motion/react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; + +export interface ChartBarIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface ChartBarIconProps extends HTMLMotionProps<"div"> { + size?: number; + duration?: number; + isAnimated?: boolean; +} + +const ChartBarIcon = forwardRef( + ( + { + onMouseEnter, + onMouseLeave, + className, + size = 24, + duration = 1, + isAnimated = true, + ...props + }, + ref, + ) => { + const controls = useAnimation(); + const reduced = useReducedMotion(); + const isControlled = useRef(false); + + useImperativeHandle(ref, () => { + isControlled.current = true; + return { + startAnimation: () => + reduced ? controls.start("normal") : controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleEnter = useCallback( + (e?: React.MouseEvent) => { + if (!isAnimated || reduced) return; + if (!isControlled.current) controls.start("animate"); + else onMouseEnter?.(e as any); + }, + [controls, reduced, isAnimated, onMouseEnter], + ); + + const handleLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlled.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e as any); + } + }, + [controls, onMouseLeave], + ); + + const pathVariants: Variants = { + normal: { + pathLength: 1, + opacity: 1, + transition: { duration: 0.2 * duration }, + }, + animate: { + pathLength: [0, 1], + opacity: [0.7, 1], + transition: { + duration: 0.6 * duration, + ease: "easeInOut", + }, + }, + }; + + const chartVariants: Variants = { + normal: { + scale: 1, + transition: { duration: 0.2 * duration }, + }, + animate: { + scale: [1, 1.05, 1], + transition: { + duration: 0.6 * duration, + ease: "easeInOut", + }, + }, + }; + + return ( + + + + + + + + + ); + }, +); + +ChartBarIcon.displayName = "ChartBarIcon"; +export { ChartBarIcon }; \ No newline at end of file diff --git a/apps/web/components/CheckIcon.tsx b/apps/web/components/CheckIcon.tsx new file mode 100644 index 0000000..3ed6139 --- /dev/null +++ b/apps/web/components/CheckIcon.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { cn } from "~/lib/utils"; +import type { HTMLMotionProps, Variants } from "motion/react"; +import { motion, useAnimation, useReducedMotion } from "motion/react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; + +export interface DoubleCheckHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface CheckCheckIconProps extends HTMLMotionProps<"div"> { + size?: number; + duration?: number; + isAnimated?: boolean; +} + +const CheckCheckIcon = forwardRef( + ( + { + onMouseEnter, + onMouseLeave, + className, + size = 24, + duration = 1, + isAnimated = true, + ...props + }, + ref, + ) => { + const controls = useAnimation(); + const reduced = useReducedMotion(); + const isControlled = useRef(false); + + useImperativeHandle(ref, () => { + isControlled.current = true; + return { + startAnimation: () => + reduced ? controls.start("normal") : controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleEnter = useCallback( + (e?: React.MouseEvent) => { + if (!isAnimated || reduced) return; + if (!isControlled.current) controls.start("animate"); + else onMouseEnter?.(e as any); + }, + [controls, reduced, isAnimated, onMouseEnter], + ); + + const handleLeave = useCallback( + (e?: React.MouseEvent) => { + if (!isControlled.current) controls.start("normal"); + else onMouseLeave?.(e as any); + }, + [controls, onMouseLeave], + ); + + const tick1Variants: Variants = { + normal: { strokeDashoffset: 0, scale: 1, opacity: 1 }, + animate: { + strokeDashoffset: [20, 0], + scale: [1, 1.2, 1], + opacity: [0.5, 1], + transition: { duration: 0.7 * duration, ease: "easeInOut" }, + }, + }; + + const tick2Variants: Variants = { + normal: { opacity: 1, x: 0 }, + animate: { + opacity: [0, 1], + x: [-6, 0], + transition: { duration: 0.5 * duration, ease: "easeOut", delay: 0.35 }, + }, + }; + + return ( + + + + + + + ); + }, +); + +CheckCheckIcon.displayName = "CheckCheckIcon"; +export { CheckCheckIcon }; \ No newline at end of file diff --git a/apps/web/components/CopyIcon.tsx b/apps/web/components/CopyIcon.tsx new file mode 100644 index 0000000..2a71a8a --- /dev/null +++ b/apps/web/components/CopyIcon.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { cn } from "~/lib/utils"; +import type { HTMLMotionProps, Transition } from "motion/react"; +import { motion, useAnimation, useReducedMotion } from "motion/react"; +import { forwardRef, useCallback, useImperativeHandle, useRef } from "react"; + +export interface CopyIconHandle { + startAnimation: () => void; + stopAnimation: () => void; +} + +interface CopyIconProps extends HTMLMotionProps<"div"> { + size?: number; + duration?: number; + isAnimated?: boolean; +} + +const CopyIcon = forwardRef( + ( + { + onMouseEnter, + onMouseLeave, + className, + size = 24, + duration = 1, + isAnimated = true, + ...props + }, + ref, + ) => { + const controls = useAnimation(); + const reduced = useReducedMotion(); + const isControlled = useRef(false); + + useImperativeHandle(ref, () => { + isControlled.current = true; + return { + startAnimation: () => + reduced ? controls.start("normal") : controls.start("animate"), + stopAnimation: () => controls.start("normal"), + }; + }); + + const handleEnter = useCallback( + (e?: React.MouseEvent) => { + if (!isAnimated || reduced) return; + if (!isControlled.current) controls.start("animate"); + else onMouseEnter?.(e as any); + }, + [controls, reduced, isAnimated, onMouseEnter], + ); + + const handleLeave = useCallback( + (e: React.MouseEvent) => { + if (!isControlled.current) { + controls.start("normal"); + } else { + onMouseLeave?.(e as any); + } + }, + [controls, onMouseLeave], + ); + const defaultTransition: Transition = { + type: "spring", + stiffness: 160, + damping: 17, + mass: 1, + }; + + const boxVariants = { + normal: { translateY: 0, translateX: 0, rotate: 0 }, + animate: { translateY: -3, translateX: -3, rotate: 360 }, + }; + + const pathVariants = { + normal: { x: 0, y: 0 }, + animate: { x: 3, y: 3 }, + }; + + return ( + + + + + + + ); + }, +); + +CopyIcon.displayName = "CopyIcon"; + +export { CopyIcon }; \ No newline at end of file diff --git a/apps/web/components/Cubes.tsx b/apps/web/components/Cubes.tsx new file mode 100644 index 0000000..430c001 --- /dev/null +++ b/apps/web/components/Cubes.tsx @@ -0,0 +1,382 @@ +"use client"; + +import React, { useCallback, useEffect, useRef } from 'react'; +import gsap from 'gsap'; + +interface Gap { + row: number; + col: number; +} +interface Duration { + enter: number; + leave: number; +} + +export interface CubesProps { + gridSize?: number; + cubeSize?: number; + maxAngle?: number; + radius?: number; + easing?: gsap.EaseString; + duration?: Duration; + cellGap?: number | Gap; + borderStyle?: string; + faceColor?: string; + shadow?: boolean | string; + autoAnimate?: boolean; + rippleOnClick?: boolean; + rippleColor?: string; + rippleSpeed?: number; +} + +const Cubes: React.FC = ({ + gridSize = 10, + cubeSize, + maxAngle = 45, + radius = 3, + easing = 'power3.out', + duration = { enter: 0.3, leave: 0.6 }, + cellGap, + borderStyle = '1px solid #fff', + faceColor = '#120F17', + shadow = false, + autoAnimate = true, + rippleOnClick = true, + rippleColor = '#fff', + rippleSpeed = 2 +}) => { + const sceneRef = useRef(null); + const rafRef = useRef(null); + const idleTimerRef = useRef | null>(null); + const userActiveRef = useRef(false); + const simPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const simTargetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); + const simRAFRef = useRef(null); + + const colGap = + typeof cellGap === 'number' + ? `${cellGap}px` + : (cellGap as Gap)?.col !== undefined + ? `${(cellGap as Gap).col}px` + : '5%'; + const rowGap = + typeof cellGap === 'number' + ? `${cellGap}px` + : (cellGap as Gap)?.row !== undefined + ? `${(cellGap as Gap).row}px` + : '5%'; + + const enterDur = duration.enter; + const leaveDur = duration.leave; + + const tiltAt = useCallback( + (rowCenter: number, colCenter: number) => { + if (!sceneRef.current) return; + sceneRef.current.querySelectorAll('.cube').forEach(cube => { + const r = +cube.dataset.row!; + const c = +cube.dataset.col!; + const dist = Math.hypot(r - rowCenter, c - colCenter); + if (dist <= radius) { + const pct = 1 - dist / radius; + const angle = pct * maxAngle; + gsap.to(cube, { + duration: enterDur, + ease: easing, + overwrite: true, + rotateX: -angle, + rotateY: angle + }); + } else { + gsap.to(cube, { + duration: leaveDur, + ease: 'power3.out', + overwrite: true, + rotateX: 0, + rotateY: 0 + }); + } + }); + }, + [radius, maxAngle, enterDur, leaveDur, easing] + ); + + const onPointerMove = useCallback( + (e: PointerEvent) => { + userActiveRef.current = true; + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + + const rect = sceneRef.current!.getBoundingClientRect(); + const cellW = rect.width / gridSize; + const cellH = rect.height / gridSize; + const colCenter = (e.clientX - rect.left) / cellW; + const rowCenter = (e.clientY - rect.top) / cellH; + + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => tiltAt(rowCenter, colCenter)); + + idleTimerRef.current = setTimeout(() => { + userActiveRef.current = false; + }, 3000); + }, + [gridSize, tiltAt] + ); + + const resetAll = useCallback(() => { + if (!sceneRef.current) return; + sceneRef.current.querySelectorAll('.cube').forEach(cube => + gsap.to(cube, { + duration: leaveDur, + rotateX: 0, + rotateY: 0, + ease: 'power3.out' + }) + ); + }, [leaveDur]); + + const onTouchMove = useCallback( + (e: TouchEvent) => { + e.preventDefault(); + userActiveRef.current = true; + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + + const rect = sceneRef.current!.getBoundingClientRect(); + const cellW = rect.width / gridSize; + const cellH = rect.height / gridSize; + + const touch = e.touches[0]; + if (!touch) return; + const colCenter = (touch.clientX - rect.left) / cellW; + const rowCenter = (touch.clientY - rect.top) / cellH; + + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => tiltAt(rowCenter, colCenter)); + + idleTimerRef.current = setTimeout(() => { + userActiveRef.current = false; + }, 3000); + }, + [gridSize, tiltAt] + ); + + const onTouchStart = useCallback(() => { + userActiveRef.current = true; + }, []); + + const onTouchEnd = useCallback(() => { + if (!sceneRef.current) return; + resetAll(); + }, [resetAll]); + + const onClick = useCallback( + (e: MouseEvent | TouchEvent) => { + if (!rippleOnClick || !sceneRef.current) return; + const rect = sceneRef.current.getBoundingClientRect(); + const cellW = rect.width / gridSize; + const cellH = rect.height / gridSize; + + const touchPoint = (e as TouchEvent).touches?.[0]; + const clientX = (e as MouseEvent).clientX || touchPoint?.clientX || 0; + const clientY = (e as MouseEvent).clientY || touchPoint?.clientY || 0; + + const colHit = Math.floor((clientX - rect.left) / cellW); + const rowHit = Math.floor((clientY - rect.top) / cellH); + + const baseRingDelay = 0.15; + const baseAnimDur = 0.3; + const baseHold = 0.6; + + const spreadDelay = baseRingDelay / rippleSpeed; + const animDuration = baseAnimDur / rippleSpeed; + const holdTime = baseHold / rippleSpeed; + + const rings: Record = {}; + sceneRef.current.querySelectorAll('.cube').forEach(cube => { + const r = +cube.dataset.row!; + const c = +cube.dataset.col!; + const dist = Math.hypot(r - rowHit, c - colHit); + const ring = Math.round(dist); + if (!rings[ring]) rings[ring] = []; + rings[ring].push(cube); + }); + + Object.keys(rings) + .map(Number) + .sort((a, b) => a - b) + .forEach(ring => { + const delay = ring * spreadDelay; + const faces = (rings[ring] ?? []).flatMap(cube => + Array.from(cube.querySelectorAll('.cube-face')) + ); + + gsap.to(faces, { + backgroundColor: rippleColor, + duration: animDuration, + delay, + ease: 'power3.out' + }); + gsap.to(faces, { + backgroundColor: faceColor, + duration: animDuration, + delay: delay + animDuration + holdTime, + ease: 'power3.out' + }); + }); + }, + [rippleOnClick, gridSize, faceColor, rippleColor, rippleSpeed] + ); + + useEffect(() => { + if (!autoAnimate || !sceneRef.current) return; + simPosRef.current = { + x: Math.random() * gridSize, + y: Math.random() * gridSize + }; + simTargetRef.current = { + x: Math.random() * gridSize, + y: Math.random() * gridSize + }; + const speed = 0.02; + const loop = () => { + if (!userActiveRef.current) { + const pos = simPosRef.current; + const tgt = simTargetRef.current; + pos.x += (tgt.x - pos.x) * speed; + pos.y += (tgt.y - pos.y) * speed; + tiltAt(pos.y, pos.x); + if (Math.hypot(pos.x - tgt.x, pos.y - tgt.y) < 0.1) { + simTargetRef.current = { + x: Math.random() * gridSize, + y: Math.random() * gridSize + }; + } + } + simRAFRef.current = requestAnimationFrame(loop); + }; + simRAFRef.current = requestAnimationFrame(loop); + return () => { + if (simRAFRef.current != null) cancelAnimationFrame(simRAFRef.current); + }; + }, [autoAnimate, gridSize, tiltAt]); + + useEffect(() => { + const el = sceneRef.current; + if (!el) return; + el.addEventListener('pointermove', onPointerMove); + el.addEventListener('pointerleave', resetAll); + el.addEventListener('click', onClick); + + el.addEventListener('touchmove', onTouchMove, { passive: false }); + el.addEventListener('touchstart', onTouchStart, { passive: true }); + el.addEventListener('touchend', onTouchEnd, { passive: true }); + + return () => { + el.removeEventListener('pointermove', onPointerMove); + el.removeEventListener('pointerleave', resetAll); + el.removeEventListener('click', onClick); + + el.removeEventListener('touchmove', onTouchMove); + el.removeEventListener('touchstart', onTouchStart); + el.removeEventListener('touchend', onTouchEnd); + + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + }; + }, [onPointerMove, resetAll, onClick, onTouchMove, onTouchStart, onTouchEnd]); + + const cells = Array.from({ length: gridSize }); + const sceneStyle: React.CSSProperties = { + gridTemplateColumns: cubeSize ? `repeat(${gridSize}, ${cubeSize}px)` : `repeat(${gridSize}, 1fr)`, + gridTemplateRows: cubeSize ? `repeat(${gridSize}, ${cubeSize}px)` : `repeat(${gridSize}, 1fr)`, + columnGap: colGap, + rowGap: rowGap, + perspective: '99999999px', + gridAutoRows: '1fr' + }; + const wrapperStyle = { + '--cube-face-border': borderStyle, + '--cube-face-bg': faceColor, + '--cube-face-shadow': shadow === true ? '0 0 6px rgba(0,0,0,.5)' : shadow || 'none', + ...(cubeSize + ? { + width: `${gridSize * cubeSize}px`, + height: `${gridSize * cubeSize}px` + } + : {}) + } as React.CSSProperties; + + return ( +
+
+ {cells.map((_, r) => + cells.map((__, c) => ( +
+ + +
+
+
+
+
+
+
+ )) + )} +
+
+ ); +}; + +export default Cubes; \ No newline at end of file diff --git a/apps/web/components/FeedbackWidget.tsx b/apps/web/components/FeedbackWidget.tsx new file mode 100644 index 0000000..ad01630 --- /dev/null +++ b/apps/web/components/FeedbackWidget.tsx @@ -0,0 +1,255 @@ +"use client"; + +import React, { FormEvent, useEffect, useRef, useState } from 'react'; +import { MessageSquareWarning, X, Send, Bug, MessageCircle, Lightbulb, AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; +import { env } from '~/env.js'; +import { useGetLoggedInUserInfo } from '~/hooks/api/auth'; +import { cn } from '~/lib/utils'; +import { Button } from '~/components/ui/button'; +import { Input } from '~/components/ui/input'; +import { Textarea } from '~/components/ui/textarea'; + +type ComplaintType = 'bug' | 'feedback' | 'complaint' | 'feature_request'; + +const typeConfig: Record = { + feedback: { + label: 'Feedback', + description: 'Share product thoughts', + icon: , + }, + bug: { + label: 'Bug report', + description: 'Something is broken', + icon: , + }, + feature_request: { + label: 'Feature request', + description: 'Request an improvement', + icon: , + }, + complaint: { + label: 'Complaint', + description: 'Escalate an issue', + icon: , + }, +}; + +const FeedbackWidget = () => { + const [isOpen, setIsOpen] = useState(false); + const [type, setType] = useState('feedback'); + const [subject, setSubject] = useState(''); + const [message, setMessage] = useState(''); + const [loading, setLoading] = useState(false); + const subjectRef = useRef(null); + const { userInfo: user } = useGetLoggedInUserInfo(); + + useEffect(() => { + if (!isOpen) return; + + const focusTimer = window.setTimeout(() => subjectRef.current?.focus(), 100); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsOpen(false); + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.clearTimeout(focusTimer); + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + + if (!subject.trim() || !message.trim()) { + toast.error('Please fill in all fields'); + return; + } + + setLoading(true); + try { + const apiBase = (env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000').replace(/\/trpc$/, ''); + + const response = await fetch(`${apiBase}/api/complaints`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userId: user?.id ?? null, + userEmail: user?.email ?? 'anonymous', + type, + subject: subject.trim(), + message: message.trim(), + status: 'open', + priority: type === 'bug' ? 'high' : 'medium', + }), + }); + + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`); + } + + toast.success('Submitted successfully! We\'ll get back to you.'); + setSubject(''); + setMessage(''); + setType('feedback'); + setIsOpen(false); + } catch (err) { + console.error(err); + toast.error('Failed to submit. Please try again.'); + } finally { + setLoading(false); + } + }; + + const canSubmit = Boolean(subject.trim() && message.trim()) && !loading; + + return ( + <> + + + {isOpen && ( +
+
+
+

+ Report / Feedback +

+

+ Send bugs, feedback, complaints, or requests. +

+
+ +
+ +
+
+
+ +
+ {(Object.entries(typeConfig) as [ComplaintType, typeof typeConfig[ComplaintType]][]).map(([key, cfg]) => ( + + ))} +
+
+ +
+ + setSubject(e.target.value)} + placeholder="Brief title" + maxLength={100} + disabled={loading} + className="border-foreground/20 font-medium focus-visible:ring-accent" + /> +
+ +
+
+ + {message.length}/1000 +
+