diff options
| author | alexf37 <[email protected]> | 2025-10-01 21:59:53 +0000 |
|---|---|---|
| committer | alexf37 <[email protected]> | 2025-10-01 21:59:53 +0000 |
| commit | ede0f393030e1006fa5463394e7d1219ca74e1f3 (patch) | |
| tree | dde233d1d7359b74c258037acd7251a67e01222e /apps | |
| parent | feat: Claude memory integration (diff) | |
| download | supermemory-ede0f393030e1006fa5463394e7d1219ca74e1f3.tar.xz supermemory-ede0f393030e1006fa5463394e7d1219ca74e1f3.zip | |
feat: new onboarding flow (#408)09-03-feat_new_onboarding_flow
This is the onboarding flow that you have all seen in my demo.
Diffstat (limited to 'apps')
22 files changed, 2522 insertions, 7 deletions
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index e6d9094d..8b1adc85 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { Inter, JetBrains_Mono } from "next/font/google"; +import { Inter, JetBrains_Mono, Instrument_Serif } from "next/font/google"; import "../globals.css"; import "@ui/globals.css"; import { AuthProvider } from "@lib/auth-context"; @@ -11,6 +11,8 @@ import { Suspense } from "react"; import { Toaster } from "sonner"; import { TourProvider } from "@/components/tour"; import { MobilePanelProvider } from "@/lib/mobile-panel-context"; +import { NuqsAdapter } from 'nuqs/adapters/next/app' + import { ViewModeProvider } from "@/lib/view-mode-context"; @@ -24,6 +26,12 @@ const mono = JetBrains_Mono({ variable: "--font-mono", }); +const serif = Instrument_Serif({ + subsets: ["latin"], + variable: "--font-serif", + weight: ["400"], +}); + export const metadata: Metadata = { metadataBase: new URL("https://app.supermemory.ai"), description: "Your memories, wherever you are", @@ -38,7 +46,7 @@ export default function RootLayout({ return ( <html className="dark bg-sm-black" lang="en"> <body - className={`${sans.variable} ${mono.variable} antialiased bg-[#0f1419]`} + className={`${sans.variable} ${mono.variable} ${serif.variable} antialiased bg-[#0f1419] overflow-x-hidden`} > <AutumnProvider backendUrl={ @@ -52,10 +60,12 @@ export default function RootLayout({ <MobilePanelProvider> <PostHogProvider> <ErrorTrackingProvider> - <TourProvider> - <Suspense>{children}</Suspense> - <Toaster richColors theme="dark" /> - </TourProvider> + <NuqsAdapter> + <TourProvider> + <Suspense>{children}</Suspense> + <Toaster richColors theme="dark" /> + </TourProvider> + </NuqsAdapter> </ErrorTrackingProvider> </PostHogProvider> </MobilePanelProvider> diff --git a/apps/web/app/onboarding/animated-text.tsx b/apps/web/app/onboarding/animated-text.tsx new file mode 100644 index 00000000..c3616482 --- /dev/null +++ b/apps/web/app/onboarding/animated-text.tsx @@ -0,0 +1,53 @@ +'use client'; +import { useEffect } from 'react'; +import { TextEffect } from '@/components/text-effect'; + +export function AnimatedText({ children, trigger, delay }: { children: string, trigger: boolean, delay: number }) { + const blurSlideVariants = { + container: { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.01 }, + }, + exit: { + transition: { staggerChildren: 0.01, staggerDirection: 1 }, + }, + }, + item: { + hidden: { + opacity: 0, + filter: 'blur(10px) brightness(0%)', + y: 0, + }, + visible: { + opacity: 1, + y: 0, + filter: 'blur(0px) brightness(100%)', + transition: { + duration: 0.4, + }, + }, + exit: { + opacity: 0, + y: -30, + filter: 'blur(10px) brightness(0%)', + transition: { + duration: 0.3, + }, + }, + }, + }; + + return ( + <TextEffect + className='inline-flex' + per='char' + variants={blurSlideVariants} + trigger={trigger} + delay={delay} + > + {children} + </TextEffect> + ); +} diff --git a/apps/web/app/onboarding/bio-form.tsx b/apps/web/app/onboarding/bio-form.tsx new file mode 100644 index 00000000..984309a9 --- /dev/null +++ b/apps/web/app/onboarding/bio-form.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { Textarea } from "@ui/components/textarea"; +import { useOnboarding } from "./onboarding-context"; +import { useState } from "react"; +import { Button } from "@ui/components/button"; +import { AnimatePresence, motion } from "motion/react"; +import { NavMenu } from "./nav-menu"; +import { $fetch } from "@lib/api"; + +export function BioForm() { + const [bio, setBio] = useState(""); + const { totalSteps, nextStep, getStepNumberFor } = useOnboarding(); + + function handleNext() { + const trimmed = bio.trim(); + if (!trimmed) { + nextStep(); + return; + } + + nextStep(); + void $fetch("@post/memories", { + body: { + content: trimmed, + containerTags: ["sm_project_default"], + metadata: { sm_source: "consumer" }, + }, + }).catch((error) => { + console.error("Failed to save onboarding bio memory:", error); + }); + } + return ( + <div className="relative"> + <div className="space-y-4"> + <NavMenu> + <p className="text-base text-zinc-600"> + Step {getStepNumberFor("bio")} of {totalSteps} + </p> + </NavMenu> + <h1 className="max-sm:text-4xl">Tell us about yourself</h1> + <p className="text-zinc-600 text-2xl max-sm:text-lg"> + What should Supermemory know about you? + </p> + </div> + <Textarea + autoFocus + className="font-sans mt-6 text-base! tracking-normal font-medium border bg-white! border-zinc-200 rounded-lg !field-sizing-normal !min-h-[calc(3*1.5rem+1rem)]" + placeholder="I'm a software engineer from San Francisco..." + rows={3} + value={bio} + onChange={(e) => setBio(e.target.value)} + /> + <AnimatePresence + mode="sync"> + { + bio ? ( + <motion.div + key="save" + initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} + exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + transition={{ duration: 0.2, ease: "easeOut" }} + className="flex justify-end mt-2 absolute -bottom-12 right-0"> + + <Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={handleNext}> + Save & Continue + </Button> + </motion.div> + ) : <motion.div + key="skip" + initial={{ opacity: 0, filter: "blur(5px)", }} + animate={{ opacity: 1, filter: "blur(0px)", }} + exit={{ opacity: 0, filter: "blur(5px)", }} + transition={{ duration: 0.2, ease: "easeOut" }} + className="flex justify-end mt-2 absolute -bottom-12 right-0"> + + <Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={handleNext}> + Skip For Now + </Button> + </motion.div> + } + </AnimatePresence> + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/app/onboarding/connections-form.tsx b/apps/web/app/onboarding/connections-form.tsx new file mode 100644 index 00000000..f71f17ce --- /dev/null +++ b/apps/web/app/onboarding/connections-form.tsx @@ -0,0 +1,213 @@ +"use client"; + +import { motion, type Transition } from "framer-motion"; +import { Button } from "@ui/components/button"; +import { useOnboarding } from "./onboarding-context"; +import { $fetch } from "@lib/api"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ConnectionResponseSchema } from "@repo/validation/api"; +import type { z } from "zod"; +import { Check } from "lucide-react"; +import { toast } from "sonner"; +import { analytics } from "@/lib/analytics"; +import { useProject } from "@/stores"; +import { NavMenu } from "./nav-menu"; + +type Connection = z.infer<typeof ConnectionResponseSchema>; + +const CONNECTORS = { + "google-drive": { + title: "Google Drive", + description: "Supermemory can use the documents and files in your Google Drive to better understand and assist you.", + iconSrc: "/images/gdrive.svg", + }, + notion: { + title: "Notion", + description: "Help Supermemory understand how you organize your life and what you have going on by connecting your Notion account.", + iconSrc: "/images/notion.svg", + }, + onedrive: { + title: "OneDrive", + description: "By integrating with OneDrive, Supermemory can better understand both your previous and your current work.", + iconSrc: "/images/onedrive.svg", + }, +} as const; + +type ConnectorProvider = keyof typeof CONNECTORS; + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { staggerChildren: 0.15, delayChildren: 0.1 } satisfies Transition, + }, +}; + +const itemVariants = { + hidden: { opacity: 0, y: 16 }, + visible: { + opacity: 1, + y: 0, + transition: { type: "spring", stiffness: 500, damping: 35, mass: 0.8 } satisfies Transition, + }, +}; + +function ConnectionCard({ + title, + description, + iconSrc, + isConnected = false, + onConnect, + isConnecting = false +}: { + title: string; + description: string; + iconSrc: string; + isConnected?: boolean; + onConnect?: () => void; + isConnecting?: boolean; +}) { + return ( + <div className="flex items-center max-sm:items-start max-sm:gap-3 max-sm:flex-col shadow-lg bg-white rounded-xl py-4 px-6 gap-6 border border-zinc-200"> + <img src={iconSrc} alt={title} className="size-10 max-sm:hidden" /> + <div className="flex flex-col gap-0.5 max-sm:gap-2"> + <div className="flex items-center gap-3"> + <img src={iconSrc} alt={title} className="size-5 sm:hidden" /> + <h3 className="text-lg font-medium">{title}</h3> + </div> + <p className="text-zinc-600 text-sm">{description}</p> + </div> + <div className="sm:ml-auto max-sm:w-full"> + {isConnected ? ( + <Button + variant="outline" + className="max-sm:w-full border border-green-200 bg-green-50 text-green-700 hover:bg-green-100 cursor-default" + disabled + > + <Check className="w-4 h-4 mr-2" /> + Connected + </Button> + ) : ( + <Button + variant="outline" + className="max-sm:w-full border border-zinc-200! hover:text-zinc-900 text-zinc-900 cursor-pointer" + onClick={onConnect} + disabled={isConnecting} + > + Connect + </Button> + )} + </div> + </div> + ); +} + +export function ConnectionsForm() { + const { totalSteps, nextStep, getStepNumberFor } = useOnboarding(); + const { selectedProject } = useProject(); + + const { data: connections = [] } = useQuery({ + queryKey: ["connections"], + queryFn: async () => { + const response = await $fetch("@post/connections/list", { + body: { + containerTags: [], + }, + }); + + if (response.error) { + throw new Error( + response.error?.message || "Failed to load connections", + ); + } + + return response.data as Connection[]; + }, + staleTime: 30 * 1000, + refetchInterval: 60 * 1000, + }); + + const addConnectionMutation = useMutation({ + mutationFn: async (provider: ConnectorProvider) => { + const response = await $fetch("@post/connections/:provider", { + params: { provider }, + body: { + redirectUrl: window.location.href, + containerTags: [selectedProject], + }, + }); + + // biome-ignore lint/style/noNonNullAssertion: its fine + if ("data" in response && !("error" in response.data!)) { + return response.data; + } + + throw new Error(response.error?.message || "Failed to connect"); + }, + onSuccess: (data, provider) => { + analytics.connectionAdded(provider); + analytics.connectionAuthStarted(); + if (data?.authLink) { + window.location.href = data.authLink; + } + }, + onError: (error, provider) => { + analytics.connectionAuthFailed(); + toast.error(`Failed to connect ${provider}`, { + description: error instanceof Error ? error.message : "Unknown error", + }); + }, + }); + + function isConnectorConnected(provider: ConnectorProvider): boolean { + return connections.some(connection => connection.provider === provider); + } + + function handleConnect(provider: ConnectorProvider) { + addConnectionMutation.mutate(provider); + } + + return ( + <div className="relative"> + <div className="space-y-4"> + <NavMenu> + <p className="text-base text-zinc-600"> + Step {getStepNumberFor("connections")} of {totalSteps} + </p> + </NavMenu> + <h1 className="max-sm:text-4xl">Connect your accounts</h1> + <p className="text-zinc-600 text-2xl max-sm:text-lg"> + Help Supermemory get to know you and your documents better + {/* The more context you provide, the better Supermemory becomes */} + {/* Supermemory understands your needs and goals better with more context */} + {/* Supermemory understands you better when it integrates with your apps */} + </p> + </div> + <motion.div className="font-sans text-base mt-8 font-normal tracking-normal flex flex-col gap-4 max-w-prose" initial="hidden" animate="visible" variants={containerVariants}> + {Object.entries(CONNECTORS).map(([provider, config]) => { + const providerKey = provider as ConnectorProvider; + const isConnected = isConnectorConnected(providerKey); + const isConnecting = addConnectionMutation.isPending && addConnectionMutation.variables === providerKey; + + return ( + <motion.div key={provider} variants={itemVariants}> + <ConnectionCard + title={config.title} + description={config.description} + iconSrc={config.iconSrc} + isConnected={isConnected} + onConnect={() => handleConnect(providerKey)} + isConnecting={isConnecting} + /> + </motion.div> + ); + })} + </motion.div> + <div className="flex justify-end mt-4"> + <Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={nextStep}> + Skip For Now + </Button> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/app/onboarding/extension-form.tsx b/apps/web/app/onboarding/extension-form.tsx new file mode 100644 index 00000000..8ce40b99 --- /dev/null +++ b/apps/web/app/onboarding/extension-form.tsx @@ -0,0 +1,707 @@ +"use client"; + +import { ArrowUpIcon, MicIcon, PlusIcon, MousePointer2, LoaderIcon, CheckIcon, XIcon, ChevronRightIcon } from "lucide-react"; +import { NavMenu } from "./nav-menu"; +import { useOnboarding } from "./onboarding-context"; +import { motion, AnimatePresence, type ResolvedValues } from "framer-motion"; +import { useEffect, useMemo, useRef, useState, useLayoutEffect } from "react"; +import React from "react"; +import { cn } from "@lib/utils"; +import { Button } from "@ui/components/button"; + +type CursorAction = + | { type: 'startAt'; target: React.RefObject<HTMLElement | null> } + | { type: 'startAtPercent'; xPercent: number; yPercent: number } + | { type: 'move'; target: React.RefObject<HTMLElement | null>; duration: number } + | { type: 'move'; xPercent: number; yPercent: number; duration: number } + | { type: 'pause'; duration: number } + | { type: 'click' } + | { type: 'call'; fn: () => void } + +interface CursorProps { + actions: CursorAction[]; + className?: string; + onPositionChange?: (clientX: number, clientY: number) => void; +} + +function useContainerRect(ref: React.RefObject<HTMLDivElement | null>) { + const rectRef = React.useRef<DOMRect | null>(null); + + useLayoutEffect(function setup() { + if (!ref.current) return; + + function measure() { + if (ref.current) { + rectRef.current = ref.current.getBoundingClientRect(); + } + } + + measure(); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(function onResize() { + measure(); + }); + if (ref.current) { + resizeObserver.observe(ref.current); + } + } + + function onScroll() { + measure(); + } + + window.addEventListener("resize", measure); + window.addEventListener("scroll", onScroll, true); + + return function cleanup() { + if (resizeObserver) { + resizeObserver.disconnect(); + } + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", onScroll, true); + }; + }, [ref]); + + return rectRef; +} + +function Cursor({ actions, className, onPositionChange }: CursorProps) { + const [position, setPosition] = useState({ x: '0px', y: '0px' }); + const [scale, setScale] = useState(1); + const [currentMoveDuration, setCurrentMoveDuration] = useState(0.7); // Default move duration + const containerRef = useRef<HTMLDivElement>(null); + const timeoutsRef = useRef<number[]>([]); + const containerRectRef = useContainerRect(containerRef); + const lastUpdateRef = useRef(0); + + function moveToElement(elementRef: React.RefObject<HTMLElement | null>, duration: number) { + if (!containerRef.current || !elementRef.current) return; + + setCurrentMoveDuration(duration / 1000); // Convert to seconds for Framer Motion + + const containerRect = containerRef.current.getBoundingClientRect(); + const elementRect = elementRef.current.getBoundingClientRect(); + // Position the TOP-LEFT of the cursor at the center of the target element + const x = elementRect.left - containerRect.left + elementRect.width / 2; + const y = elementRect.top - containerRect.top + elementRect.height / 2; + + setPosition({ x: `${x}px`, y: `${y}px` }); + } + + function setPositionByPercent(xPercent: number, yPercent: number) { + if (!containerRef.current) return; + const containerRect = containerRef.current.getBoundingClientRect(); + // Percentages indicate where the TOP-LEFT of the cursor should be placed + const x = (containerRect.width * xPercent) / 100; + const y = (containerRect.height * yPercent) / 100; + setCurrentMoveDuration(0); // snap without animating + setPosition({ x: `${x}px`, y: `${y}px` }); + } + + function moveToPercent(xPercent: number, yPercent: number, duration: number) { + if (!containerRef.current) return; + setCurrentMoveDuration(duration / 1000); + const containerRect = containerRef.current.getBoundingClientRect(); + const x = (containerRect.width * xPercent) / 100; + const y = (containerRect.height * yPercent) / 100; + setPosition({ x: `${x}px`, y: `${y}px` }); + } + + useEffect(() => { + // Clear any existing timeouts before scheduling new ones + timeoutsRef.current.forEach((id) => clearTimeout(id)); + timeoutsRef.current = []; + + let timeAccumulator = 0; + + function schedule(callback: () => void, delay: number): number { + const id = window.setTimeout(callback, delay); + timeoutsRef.current.push(id); + return id; + } + + function executeActions(): void { + actions.forEach((action) => { + // startAt should apply immediately at its place in the sequence and not advance time + if (action.type === 'startAt') { + moveToElement(action.target, 0); + return; + } + if (action.type === 'startAtPercent') { + setPositionByPercent(action.xPercent, action.yPercent); + return; + } + + schedule(() => { + switch (action.type) { + case 'move': + if ('target' in action) { + moveToElement(action.target, action.duration); + } else { + moveToPercent(action.xPercent, action.yPercent, action.duration); + } + break; + case 'click': + setScale(0.9); + schedule(function resetClickScale() { setScale(1); }, 100); // Fixed 100ms click duration + break; + case 'call': + try { + action.fn(); + } catch (_) { + // no-op on errors to avoid breaking demo + } + break; + case 'pause': + // Pause doesn't require any action, just time passing + break; + } + }, timeAccumulator); + + // Add this action's duration to the accumulator for the next action + if (action.type === 'click') { + timeAccumulator += 100; + } else if (action.type === 'pause') { + timeAccumulator += action.duration; + } else if (action.type === 'move') { + timeAccumulator += action.duration; + } else { + // 'call' and startAt/startAtPercent don't consume time + } + }); + } + + // make sure refs are ready + schedule(executeActions, 100); + + return function cleanup(): void { + timeoutsRef.current.forEach((id) => clearTimeout(id)); + timeoutsRef.current = []; + }; + }, [actions]); + + return ( + <div ref={containerRef} className={`absolute inset-0 pointer-events-none ${className || ''}`}> + <motion.div + animate={{ + x: position.x, + y: position.y, + scale: scale + }} + transition={{ + x: { duration: currentMoveDuration, ease: "easeInOut" }, + y: { duration: currentMoveDuration, ease: "easeInOut" }, + scale: { duration: 0.1, ease: "easeInOut" } + }} + className="absolute top-0 left-0" + style={{ zIndex: 10 }} + onUpdate={(latest: ResolvedValues) => { + if (!onPositionChange) return; + const containerRect = containerRectRef.current; + if (!containerRect) return; + const now = performance.now(); + if (now - lastUpdateRef.current < 50) return; // ~20fps throttle + lastUpdateRef.current = now; + const latestX = typeof latest.x === 'number' ? latest.x : parseFloat(String(latest.x || 0)); + const latestY = typeof latest.y === 'number' ? latest.y : parseFloat(String(latest.y || 0)); + const clientX = containerRect.left + latestX; + const clientY = containerRect.top + latestY; + onPositionChange(clientX, clientY); + }} + > + <MousePointer2 className="size-6 drop-shadow" strokeWidth={1.5} fill="white" /> + </motion.div> + </div> + ) +} + +function SnippetDemo() { + const snippetRootRef = useRef<HTMLDivElement>(null); + const sentenceRef = useRef<HTMLSpanElement>(null); + const [currentEndIndex, setCurrentEndIndex] = useState<number>(0); + const lastStableIndexRef = useRef<number>(0); + const [cursorActions, setCursorActions] = useState<CursorAction[]>([]); + const [menuOpen, setMenuOpen] = useState<boolean>(false); + const [hoveredMenuIndex, setHoveredMenuIndex] = useState<number | null>(null); + const menuItemRefs = useRef<(HTMLDivElement | null)[]>([]); + const menuItem6Ref = useRef<HTMLElement | null>(null); + const charRectsRef = useRef<DOMRect[]>([]); + + const targetText = "There's an Italian dish called saltimbocca, which means \"leap into the mouth.\""; + + function getIndexFromClientPoint(clientX: number, clientY: number): number { + const rects = charRectsRef.current; + if (!sentenceRef.current || rects.length === 0) return 0; + let bestIdx = 0; + let bestDist = Number.POSITIVE_INFINITY; + for (let i = 0; i < rects.length; i++) { + const r = rects[i]; + if (!r) continue; + const cx = r.left + r.width / 2; + const cy = r.top + r.height / 2; + const dx = clientX - cx; + const dy = clientY - cy; + const d = dx * dx + dy * dy; + if (d < bestDist) { + bestDist = d; + bestIdx = i; + } + } + return bestIdx; + } + + function getMenuItemIndexFromPoint(clientX: number, clientY: number): number | null { + const el = document.elementFromPoint(clientX, clientY) as HTMLElement | null; + if (!el) return null; + + const menuItem = el.closest('[data-menu-idx]') as HTMLElement | null; + if (menuItem) { + const idx = parseInt(menuItem.dataset.menuIdx || '', 10); + return Number.isFinite(idx) ? idx : null; + } + return null; + } + + useLayoutEffect(function setupCharRectsMeasurement() { + function measureCharRects(): void { + if (!sentenceRef.current) { + charRectsRef.current = []; + return; + } + const spans = sentenceRef.current.querySelectorAll('span[data-idx]'); + const rects: DOMRect[] = []; + spans.forEach(function collect(node) { + rects.push((node as HTMLElement).getBoundingClientRect()); + }); + charRectsRef.current = rects; + } + + measureCharRects(); + + let ro1: ResizeObserver | null = null; + let ro2: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + ro1 = new ResizeObserver(function onResize() { measureCharRects(); }); + ro2 = new ResizeObserver(function onResize() { measureCharRects(); }); + if (snippetRootRef.current) ro1.observe(snippetRootRef.current); + if (sentenceRef.current) ro2.observe(sentenceRef.current); + } + + function onScroll(): void { measureCharRects(); } + window.addEventListener("resize", measureCharRects); + window.addEventListener("scroll", onScroll, true); + + return function cleanup(): void { + if (ro1) ro1.disconnect(); + if (ro2) ro2.disconnect(); + window.removeEventListener("resize", measureCharRects); + window.removeEventListener("scroll", onScroll, true); + }; + }, [targetText]); + + useEffect(function setupActionsOnce() { + lastStableIndexRef.current = 0; + setCurrentEndIndex(0); + if (!sentenceRef.current) return; + const total = targetText.length; + const firstSpan = sentenceRef.current.querySelector('span[data-idx="0"]') as HTMLSpanElement | null; + const lastSpan = sentenceRef.current.querySelector(`span[data-idx="${Math.max(0, total - 1)}"]`) as HTMLSpanElement | null; + if (!firstSpan || !lastSpan) return; + const firstRef = { current: firstSpan } as React.RefObject<HTMLElement>; + const lastRef = { current: lastSpan } as React.RefObject<HTMLElement>; + setCursorActions([ + { type: 'call', fn: function reset() { lastStableIndexRef.current = 0; setCurrentEndIndex(0); setHoveredMenuIndex(null); } }, + { type: 'startAt', target: firstRef }, + { type: 'pause', duration: 200 }, + { type: 'move', target: lastRef, duration: 1800 }, + { type: 'pause', duration: 1200 }, + { type: "click" }, + { type: 'call', fn: () => { setMenuOpen(true); } }, + { type: 'pause', duration: 1000 }, + { type: 'move', target: menuItem6Ref, duration: 1000 }, + { type: 'pause', duration: 500 }, + { type: 'click' }, + ]); + }, []); + + return ( + <div ref={snippetRootRef} className="size-full select-none text-xs relative overflow-hidden"> + <Cursor + actions={cursorActions} + onPositionChange={function onPositionChange(clientX: number, clientY: number) { + // Handle text highlighting + const textIdx = getIndexFromClientPoint(clientX, clientY); + const next = textIdx < lastStableIndexRef.current ? lastStableIndexRef.current : textIdx; + if (next !== lastStableIndexRef.current) { + lastStableIndexRef.current = next; + setCurrentEndIndex(next); + } + + // Handle menu item hovering + if (menuOpen) { + const menuIdx = getMenuItemIndexFromPoint(clientX, clientY); + if (menuIdx !== hoveredMenuIndex) { + setHoveredMenuIndex(menuIdx); + } + } + }} + /> + <div className="h-[125%] w-full bg-white text-justify p-4 text-black absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"> + writing is easier to read, and the easier something is to read, the more deeply readers will engage with it. The less energy they expend on your prose, the more they'll have left for your ideas. And the further they'll read. Most readers' energy tends to flag part way through an article or essay. If the friction of reading is low enough, more keep going till the end. + {" "} + <span ref={sentenceRef}> + {targetText.split("").map(function renderChar(ch, idx) { + const highlighted = idx <= currentEndIndex; + return ( + <span + key={idx} + data-idx={idx} + className={highlighted ? "bg-blue-200/70" : undefined} + > + {ch} + </span> + ); + })} + </span> + <span className="size-0 relative"> + {menuOpen && ( + <div className="flex flex-col w-48 text-left absolute top-0 right-0 text-white bg-zinc-800/70 text-xs border-0.5 backdrop-blur-sm border-zinc-900/80 rounded-md px-1.5 py-1.5"> + {["Back", "Forward", "Reload", "Save As...", "Print...", "Translate to English", "Save to Supermemory", "View Page Source", "Inspect"].map((item, idx) => ( + <React.Fragment key={item}> + <div + ref={el => { menuItemRefs.current[idx] = el; if (idx === 6) { menuItem6Ref.current = el; } }} + data-menu-idx={idx} + className={cn( + "px-2 py-0.5 flex items-center gap-1.5 rounded-sm transition-colors", + (idx === 0 || idx === 1) && "text-white/30", + hoveredMenuIndex === idx && "bg-blue-500" + )} + > + {idx === 6 && <img src="/images/icon-16.png" alt="Supermemory" className="size-3.5" />} + {item} + </div> + {[2, 5, 6].includes(idx) && <div className="h-px rounded-full my-1 mx-2 bg-zinc-300/20" />} + </React.Fragment> + ))} + </div> + )} + </span> + {" "}My goal when writing might be called saltintesta: the ideas leap into your head and you barely notice the words that got them there. It's too much to hope that writing could ever be pure ideas. You might not even want it to be. But for most writers, most of the time, that's the goal to aim for. The gap between most writing and pure ideas is not filled with poetry. Plus it's more considerate to write simply. When you write in a fancy way to impress people, you're making them do extra work just so you can seem cool. It's like trailing a long train behind you that readers have to carry. + </div> + </div> + ) +} + +function ChatGPTDemo() { + const iconRef = useRef<HTMLImageElement>(null); + const submitButtonRef = useRef<HTMLDivElement>(null); + const [enhancementStatus, setEnhancementStatus] = useState<"notStarted" | "enhancing" | "done">("notStarted"); + const [memoriesExpanded, setMemoriesExpanded] = useState(false); + + const cursorActions: CursorAction[] = useMemo(() => [ + { + type: "call", fn: function resetStates() { + setEnhancementStatus("notStarted"); + setMemoriesExpanded(false); + } + }, + { type: 'startAtPercent', xPercent: 80, yPercent: 80 }, + { type: 'pause', duration: 1000 }, + { type: 'move', target: iconRef, duration: 1000 }, + { type: 'pause', duration: 1000 }, + { type: 'click' }, + { type: 'call', fn: function startEnhancing() { setEnhancementStatus("enhancing"); } }, + { type: 'pause', duration: 1000 }, + { type: 'move', xPercent: 10, yPercent: 80, duration: 1000 }, + { type: 'call', fn: function finishEnhancing() { setEnhancementStatus("done"); } }, + { type: 'pause', duration: 500 }, + { type: 'move', target: iconRef, duration: 1000 }, + { type: 'call', fn: function expandMemories() { setMemoriesExpanded(true); } }, + { type: "pause", duration: 1000 }, + { type: 'move', xPercent: 80, yPercent: 80, duration: 1000 }, + ], []); + + return ( + <div className="size-full relative overflow-hidden select-none pointer-events-none text-white flex flex-col gap-6 items-center justify-center" style={{ + backgroundColor: "#212121", + fontFamily: "ui-sans-serif, -apple-system, system-ui" + }}> + <Cursor actions={cursorActions} /> + <div className="text-xl">What's on your mind today?</div> + <div className="w-[85%] text-sm rounded-3xl p-2 flex flex-col gap-2" style={{ backgroundColor: "#303030" }}> + <div className="p-2"> + what are my card's benefits? + </div> + <div className="flex justify-between items-center p-1 pt-0"> + <PlusIcon className="size-5" strokeWidth={1.5} /> + <div className="flex items-center gap-2"> + <div className="h-4.5 flex items-center"> + <motion.div + ref={iconRef} + layout + initial={false} + animate={{ + backgroundColor: enhancementStatus === "notStarted" ? "transparent" : "#1e1b4b", + borderColor: enhancementStatus === "notStarted" ? "transparent" : "#4338ca", + borderWidth: enhancementStatus === "notStarted" ? 0 : 1, + paddingLeft: enhancementStatus === "notStarted" ? 0 : (enhancementStatus === "enhancing" ? 4 : 8), + paddingRight: enhancementStatus === "notStarted" ? 0 : (enhancementStatus === "enhancing" ? 6 : 8), + paddingTop: enhancementStatus === "notStarted" ? 0 : 4, + paddingBottom: enhancementStatus === "notStarted" ? 0 : 4, + marginTop: enhancementStatus === "notStarted" ? 0 : 4, + marginBottom: enhancementStatus === "notStarted" ? 0 : 4, + }} + transition={{ + duration: 0.2, + ease: "easeInOut", + layout: { duration: 0.2, ease: "easeInOut" } + }} + className="rounded-full text-xs w-fit flex items-center relative" + style={{ border: "solid" }} + > + {enhancementStatus === "notStarted" && ( + <img src="/images/icon-16.png" alt="Enhance with Supermemory" className="size-5" /> + )} + {enhancementStatus === "enhancing" && ( + <> + <LoaderIcon className="size-3 animate-spin" /> + <motion.span + initial={{ opacity: 0, width: 0 }} + animate={{ opacity: 1, width: "auto" }} + transition={{ delay: 0.1, duration: 0.2 }} + className="ml-2 whitespace-nowrap overflow-hidden" + > + Searching... + </motion.span> + </> + )} + {enhancementStatus === "done" && ( + <> + <CheckIcon className="size-3 text-green-400" /> + <motion.span + initial={{ opacity: 0, width: "auto" }} + animate={{ opacity: 1, width: "auto" }} + transition={{ delay: 0.1, duration: 0.2 }} + className="ml-2">Including 1 memory</motion.span> + </> + )} + <AnimatePresence> + {memoriesExpanded && ( + <motion.div + initial={{ + opacity: 0, + scale: 0.95, + y: -8 + }} + animate={{ + opacity: 1, + scale: 1, + y: 0 + }} + exit={{ + opacity: 0, + scale: 0.95, + y: -8 + }} + transition={{ + duration: 0.15, + ease: [0.16, 1, 0.3, 1], + }} + className="absolute left-1/2 -translate-x-1/2 top-8 bg-[#1e1b4b] border border-[#4338ca] w-56 rounded-lg p-2" + style={{ transformOrigin: 'top center' }} + > + <div className="flex items-center gap-2"> + <img src="/images/icon-16.png" alt="Enhance with Supermemory" className="size-5" /> + <span className="text-xs">User possesses an American Express Platinum card</span> + </div> + </motion.div> + )} + </AnimatePresence> + </motion.div> + </div> + + <MicIcon className="size-4" strokeWidth={1.5} /> + <div ref={submitButtonRef} className="rounded-full bg-white size-6 flex items-center justify-center"> + <ArrowUpIcon className="size-4 text-black" /> + </div> + </div> + </div> + </div> + </div> + ) +} + +function TwitterDemo() { + const importButtonRef = useRef<HTMLButtonElement>(null); + const [importStatus, setImportStatus] = useState<"notStarted" | "importing" | "done">("notStarted"); + + const cursorActions: CursorAction[] = useMemo(() => [ + { + type: "call", fn: function resetStates() { + setImportStatus("notStarted"); + } + }, + { type: 'startAtPercent', xPercent: 10, yPercent: 80 }, + { type: 'pause', duration: 1300 }, + { type: 'move', target: importButtonRef, duration: 1100 }, + { type: 'pause', duration: 800 }, + { type: 'click' }, + { type: 'call', fn: function startImporting() { setImportStatus("importing"); } }, + { type: 'pause', duration: 700 }, + { type: 'move', xPercent: 80, yPercent: 80, duration: 1200 }, + { type: 'call', fn: function finishImporting() { setImportStatus("done"); } }, + ], []); + + return ( + <div className="size-full relative overflow-hidden select-none flex flex-col items-center justify-center"> + <div className="bg-white text-black px-5 py-3 text-sm rounded-2xl w-9/10"> + <div className="flex justify-between items-center"> + <div className="flex items-center gap-2"> + <span className="text-xl font-bold">𝕏</span> + <span className="text-base font-medium">Import Twitter Bookmarks</span> + </div> + <XIcon className="size-4" /> + </div> + <div className="mt-3"> + <p className="text-sm text-zinc-600"> + This will import all your Twitter bookmarks to Supermemory + </p> + </div> + <div className="mt-3"> + <motion.button + ref={importButtonRef} + animate={{ + backgroundColor: importStatus === "importing" + ? "#f59e0b" + : importStatus === "done" + ? "#10b981" + : "#3b82f6" + }} + transition={{ duration: 0.3, ease: "easeInOut" }} + className="text-white px-4 py-2 rounded-lg flex items-center gap-2 min-w-[180px] justify-center" + > + <AnimatePresence mode="wait"> + {importStatus === "importing" && ( + <motion.div + initial={{ opacity: 0, scale: 0 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0 }} + transition={{ duration: 0.2 }} + > + <LoaderIcon className="size-4 animate-spin" /> + </motion.div> + )} + {importStatus === "done" && ( + <motion.div + initial={{ opacity: 0, scale: 0 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0 }} + transition={{ duration: 0.2 }} + > + <CheckIcon className="size-4" /> + </motion.div> + )} + </AnimatePresence> + <motion.span + key={importStatus} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, y: -10 }} + transition={{ duration: 0.3, ease: "easeInOut" }} + > + {importStatus === "importing" + ? "Importing bookmarks..." + : importStatus === "done" + ? "Import successful" + : "Import All Bookmarks"} + </motion.span> + </motion.button> + </div> + </div> + <Cursor actions={cursorActions} /> + </div> + ) +} + +export function ExtensionForm() { + const { totalSteps, nextStep, getStepNumberFor } = useOnboarding(); + return ( + <div className="relative flex items-start flex-col gap-6"> + <div className="flex items-center justify-between w-full"> + <div className="flex flex-col items-start text-left gap-4"> + <NavMenu> + <p className="text-base text-zinc-600"> + Step {getStepNumberFor("extension")} of {totalSteps} + </p> + </NavMenu> + <h1>Install the Chrome extension</h1> + <p className="text-zinc-600 text-2xl"> + {/* Install the Supermemory extension to start saving and organizing everything that matters. */} + Bring Supermemory everywhere + </p> + </div> + <div className="flex flex-col items-center text-center gap-4"> + <a href="https://supermemory.ai/extension" target="_blank" className="bg-zinc-50/80 backdrop-blur-lg border-2 hover:bg-zinc-100/80 transition-colors duration-100 border-zinc-200/80 shadow-xs rounded-full mt-6 pl-4.5 pr-6 py-4 text-2xl font-sans tracking-tight font-medium flex items-center gap-4"> + <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Google_Chrome_icon_%28February_2022%29.svg/2048px-Google_Chrome_icon_%28February_2022%29.svg.png" alt="Chrome" className="size-8" /> + Add to Chrome + </a> + </div> + </div> + + <div className="grid grid-cols-3 w-fit gap-4 text-base font-sans font-medium tracking-normal"> + <div className="flex flex-col w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden"> + <div className="p-4 bg-white"> + <h2 className="text-lg">Remember anything, anywhere</h2> + <p className="text-zinc-600 text-sm"> + Just right-click to save instantly. + </p> + </div> + <div className="aspect-square bg-blue-500"> + <SnippetDemo /> + </div> + + </div> + <div className="flex flex-col w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden"> + + <div className="p-4 bg-white"> + <h2 className="text-lg">Integrate with your AI</h2> + {/* Supercharge your AI with memory */} + {/* Supercharge any AI with Supermemory. */} + {/* ChatGPT is better with Supermemory. */} + {/* Seamless integration with your workflow */} + <p className="text-zinc-600 text-sm"> + {/* Integrates with ChatGPT and Claude. */} + {/* Integrates with your chat apps */} + Enhance any prompt with Supermemory. + {/* Seamlessly */} + </p> + </div> + <div className="aspect-square bg-blue-500"> + <ChatGPTDemo /> + </div> + </div> + <div className="flex flex-col w-80 divide-y divide-zinc-200 border border-zinc-200 shadow-xs rounded-xl overflow-hidden"> + + <div className="p-4 bg-white"> + <h2 className="text-lg">Import Twitter bookmarks</h2> + <p className="text-zinc-600 text-sm"> + Search semantically and effortlessly. + {/* Import instantly and search effortlessly. */} + </p> + </div><div className="aspect-square bg-blue-500"> + <TwitterDemo /> + </div> + + </div> + </div> + <div className="flex w-full justify-end"> + <Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={nextStep}> + Continue + <ChevronRightIcon className="size-4" /> + </Button> + </div> + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/app/onboarding/floating-orbs.tsx b/apps/web/app/onboarding/floating-orbs.tsx new file mode 100644 index 00000000..96a8f061 --- /dev/null +++ b/apps/web/app/onboarding/floating-orbs.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import { useEffect, useMemo, useState, memo } from "react"; +import { useOnboarding } from "./onboarding-context"; + +interface OrbProps { + size: number; + initialX: number; + initialY: number; + duration: number; + delay: number; + revealDelay: number; + shouldReveal: boolean; + color: { + primary: string; + secondary: string; + tertiary: string; + }; +} + +function FloatingOrb({ size, initialX, initialY, duration, delay, revealDelay, shouldReveal, color }: OrbProps) { + const blurPixels = Math.min(64, Math.max(24, Math.floor(size * 0.08))); + + const gradient = useMemo(() => { + return `radial-gradient(circle, ${color.primary} 0%, ${color.secondary} 40%, ${color.tertiary} 70%, transparent 100%)`; + }, [color.primary, color.secondary, color.tertiary]); + + const style = useMemo(() => { + return { + width: size, + height: size, + background: gradient, + filter: `blur(${blurPixels}px)`, + willChange: "transform, opacity", + mixBlendMode: "plus-lighter", + } as any; + }, [size, gradient, blurPixels]); + + const initial = useMemo(() => { + return { + x: initialX, + y: initialY, + scale: 0, + opacity: 0, + }; + }, [initialX, initialY]); + + const animate = useMemo(() => { + if (!shouldReveal) { + return { + x: initialX, + y: initialY, + scale: 0, + opacity: 0, + }; + } + return { + x: [initialX, initialX + 200, initialX - 150, initialX + 100, initialX], + y: [initialY, initialY - 180, initialY + 120, initialY - 80, initialY], + scale: [0.8, 1.2, 0.9, 1.1, 0.8], + opacity: 0.7, + }; + }, [shouldReveal, initialX, initialY]); + + const transition = useMemo(() => { + return { + x: { + duration: shouldReveal ? duration : 0, + repeat: shouldReveal ? Infinity : 0, + ease: [0.42, 0, 0.58, 1], + delay: shouldReveal ? delay + revealDelay : 0, + }, + y: { + duration: shouldReveal ? duration : 0, + repeat: shouldReveal ? Infinity : 0, + ease: [0.42, 0, 0.58, 1], + delay: shouldReveal ? delay + revealDelay : 0, + }, + scale: { + duration: shouldReveal ? duration : 0.8, + repeat: shouldReveal ? Infinity : 0, + ease: shouldReveal ? [0.42, 0, 0.58, 1] : [0, 0, 0.58, 1], + delay: shouldReveal ? delay + revealDelay : revealDelay, + }, + opacity: { + duration: 1.2, + ease: [0, 0, 0.58, 1], + delay: shouldReveal ? revealDelay : 0, + }, + } as any; + }, [shouldReveal, duration, delay, revealDelay]); + + return ( + <motion.div + className="absolute rounded-full" + style={style} + initial={initial} + animate={animate} + transition={transition} + /> + ); +} + +const MemoFloatingOrb = memo(FloatingOrb); + +export function FloatingOrbs() { + const { orbsRevealed } = useOnboarding(); + const reduceMotion = useReducedMotion(); + const [mounted, setMounted] = useState(false); + const [orbs, setOrbs] = useState<Array<{ + id: number; + size: number; + initialX: number; + initialY: number; + duration: number; + delay: number; + revealDelay: number; + color: { + primary: string; + secondary: string; + tertiary: string; + }; + }>>([]); + + useEffect(() => { + setMounted(true); + + const screenWidth = typeof window !== "undefined" ? window.innerWidth : 1200; + const screenHeight = typeof window !== "undefined" ? window.innerHeight : 800; + + // Define edge zones (avoiding center) + const edgeThickness = Math.min(screenWidth, screenHeight) * 0.25; // 25% of smaller dimension + + // Define rainbow color palette + const colorPalette = [ + { // Magenta + primary: "rgba(255, 0, 150, 0.6)", + secondary: "rgba(255, 100, 200, 0.4)", + tertiary: "rgba(255, 150, 220, 0.1)" + }, + { // Yellow + primary: "rgba(255, 235, 59, 0.6)", + secondary: "rgba(255, 245, 120, 0.4)", + tertiary: "rgba(255, 250, 180, 0.1)" + }, + { // Light Blue + primary: "rgba(100, 181, 246, 0.6)", + secondary: "rgba(144, 202, 249, 0.4)", + tertiary: "rgba(187, 222, 251, 0.1)" + }, + { // Orange (keeping original) + primary: "rgba(255, 154, 0, 0.6)", + secondary: "rgba(255, 206, 84, 0.4)", + tertiary: "rgba(255, 154, 0, 0.1)" + }, + { // Very Light Red/Pink + primary: "rgba(255, 138, 128, 0.6)", + secondary: "rgba(255, 171, 145, 0.4)", + tertiary: "rgba(255, 205, 210, 0.1)" + } + ]; + + // Generate orb configurations positioned along edges + const newOrbs = Array.from({ length: 8 }, (_, i) => { + let x, y; + const zone = i % 4; // Rotate through 4 zones: top, right, bottom, left + + switch (zone) { + case 0: // Top edge + x = Math.random() * screenWidth; + y = Math.random() * edgeThickness; + break; + case 1: // Right edge + x = screenWidth - edgeThickness + Math.random() * edgeThickness; + y = Math.random() * screenHeight; + break; + case 2: // Bottom edge + x = Math.random() * screenWidth; + y = screenHeight - edgeThickness + Math.random() * edgeThickness; + break; + case 3: // Left edge + x = Math.random() * edgeThickness; + y = Math.random() * screenHeight; + break; + default: + x = Math.random() * screenWidth; + y = Math.random() * screenHeight; + } + + return { + id: i, + size: Math.random() * 300 + 200, // 200px to 500px + initialX: x, + initialY: y, + duration: Math.random() * 20 + 15, // 15-35 seconds (longer for more gentle movement) + delay: i * 0.4, // Staggered start for floating animation + revealDelay: i * 0.2, // Faster staggered reveal + color: colorPalette[i % colorPalette.length]!, // Cycle through rainbow colors + }; + }); + + setOrbs(newOrbs); + }, []); + + if (!mounted || orbs.length === 0) return null; + + return ( + <div + className="fixed inset-0 pointer-events-none overflow-hidden" + style={{ isolation: "isolate", contain: "paint" }} + > + {orbs.map((orb) => ( + <MemoFloatingOrb + key={orb.id} + size={orb.size} + initialX={orb.initialX} + initialY={orb.initialY} + duration={reduceMotion ? 0 : orb.duration} + delay={orb.delay} + revealDelay={orb.revealDelay} + shouldReveal={reduceMotion ? false : orbsRevealed} + color={orb.color} + /> + ))} + </div> + ); +} diff --git a/apps/web/app/onboarding/intro.tsx b/apps/web/app/onboarding/intro.tsx new file mode 100644 index 00000000..ce106b59 --- /dev/null +++ b/apps/web/app/onboarding/intro.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { AnimatedText } from "./animated-text"; +import { motion, AnimatePresence } from "motion/react"; +import { Button } from "@repo/ui/components/button"; +import { cn } from "@lib/utils"; +import { ArrowRightIcon } from "lucide-react"; +import { useOnboarding } from "./onboarding-context"; +import { useIsMobile } from "@hooks/use-mobile"; + +export function Intro() { + const { nextStep, introTriggers: triggers } = useOnboarding(); + const isMobile = useIsMobile(); + + return ( + <motion.div + className="flex flex-col gap-4 relative max-sm:text-4xl max-sm:w-full" + layout + transition={{ + layout: { duration: 0.8, ease: "anticipate" } + }} + > + <AnimatePresence mode="popLayout"> + {triggers.first && ( + <motion.div + key="first" + layout + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ + opacity: { duration: 0.3 }, + layout: { duration: 0.8, ease: "easeInOut" } + }} + > + {isMobile ? ( + <div className="flex flex-col"> + <AnimatedText trigger={triggers.first} delay={0}> + Still looking for your + </AnimatedText> + <AnimatedText trigger={triggers.first} delay={0.1}> + other half? + </AnimatedText> + </div> + ) : ( + <AnimatedText trigger={triggers.first} delay={0}> + Still looking for your other half? + </AnimatedText> + )} + </motion.div> + )} + {triggers.second && ( + <motion.div + key="second" + layout + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ + opacity: { duration: 0.3 }, + layout: { duration: 0.8, ease: "easeInOut" } + }} + > + <AnimatedText trigger={triggers.second} delay={0.4}> + Don't worry. + </AnimatedText> + </motion.div> + )} + {triggers.third && ( + <motion.div + key="third" + layout + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ + opacity: { duration: 0.3 }, + layout: { duration: 0.8, ease: "easeInOut" } + }} + > + <AnimatedText trigger={triggers.third} delay={0.4}> + {/* You just found it. */} + {/* You're about to find it. */} + It's right here. + {/* It's right in front of you. */} + {/* You're looking at it. */} + </AnimatedText> + </motion.div> + )} + </AnimatePresence> + <motion.div + key="fourth" + className="absolute -bottom-16 left-0" + initial={{ opacity: 0, filter: "blur(5px)" }} + animate={{ + opacity: triggers.fourth ? 1 : 0, + filter: triggers.fourth ? "blur(0px)" : "blur(5px)" + }} + transition={{ + opacity: { duration: 0.6, ease: "easeOut" }, + filter: { duration: 0.4, ease: "easeOut" } + }} + > + <Button + variant={"link"} + size={"lg"} + className={cn("text-zinc-600 flex items-center gap-2 hover:text-zinc-950 text-2xl underline group font-normal w-fit px-0! cursor-pointer", !triggers.fourth && "pointer-events-none")} + style={{ + transform: triggers.fourth ? "scale(1)" : "scale(0.95)" + }} + onClick={nextStep} + > + Meet Supermemory + <ArrowRightIcon className="w-4 h-4 group-hover:translate-x-0.5 transition-transform duration-200" /> + </Button> + </motion.div> + </motion.div> + ) +}
\ No newline at end of file diff --git a/apps/web/app/onboarding/mcp-form.tsx b/apps/web/app/onboarding/mcp-form.tsx new file mode 100644 index 00000000..50d48979 --- /dev/null +++ b/apps/web/app/onboarding/mcp-form.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from "@ui/components/select"; +import { useOnboarding } from "./onboarding-context"; +import { useEffect, useState } from "react"; +import { Button } from "@ui/components/button"; +import { CheckIcon, CircleCheckIcon, CopyIcon, LoaderIcon } from "lucide-react"; +import { toast } from "sonner"; +import { TextMorph } from "@/components/text-morph"; +import { NavMenu } from "./nav-menu"; +import { cn } from "@lib/utils"; +import { motion, AnimatePresence } from "framer-motion"; +import { useQuery } from "@tanstack/react-query"; +import { $fetch } from "@lib/api"; + +const clients = { + cursor: "Cursor", + claude: "Claude Desktop", + vscode: "VSCode", + cline: "Cline", + "roo-cline": "Roo Cline", + witsy: "Witsy", + enconvo: "Enconvo", + "gemini-cli": "Gemini CLI", + "claude-code": "Claude Code", +} as const; + +export function MCPForm() { + const { totalSteps, nextStep, getStepNumberFor } = useOnboarding(); + const [client, setClient] = useState<keyof typeof clients>("cursor"); + const [isCopied, setIsCopied] = useState(false); + const [isInstalling, setIsInstalling] = useState(true); + + const hasLoginQuery = useQuery({ + queryKey: ["mcp", "has-login"], + queryFn: async (): Promise<{ previousLogin: boolean }> => { + const response = await $fetch("@get/mcp/has-login"); + if (response.error) { + throw new Error(response.error?.message || "Failed to check MCP login"); + } + return response.data as { previousLogin: boolean }; + }, + enabled: isInstalling, + refetchInterval: isInstalling ? 1000 : false, + staleTime: 0, + }); + + useEffect(() => { + if (hasLoginQuery.data?.previousLogin) { + setIsInstalling(false); + } + }, [hasLoginQuery.data?.previousLogin]); + + return ( + <div className="relative flex flex-col gap-6"> + <div className="space-y-4"> + <NavMenu> + <p className="text-base text-zinc-600"> + Step {getStepNumberFor("mcp")} of {totalSteps} + </p> + </NavMenu> + <h1 className="max-sm:text-4xl">Install the MCP server</h1> + <p className="text-zinc-600 text-2xl max-sm:text-lg"> + Bring Supermemory to all your favourite tools + </p> + </div> + <div className="flex flex-col gap-4 font-sans text-base tracking-normal font-normal"> + <div className="flex gap-4"> + <div className="relative flex-shrink-0"> + <div style={{ + height: "calc(100% - 0.5rem)", + }} className="absolute -z-10 left-1/2 top-8 w-[1px] -translate-x-1/2 transform bg-neutral-200"></div> + <div className="size-10 rounded-lg bg-zinc-100 border-zinc-200 border text-zinc-900 font-medium flex items-center justify-center">1</div> + </div> + <div className="mt-2 space-y-2 w-full"> + <p>Select the app you want to install Supermemory MCP to</p> + <Select + onValueChange={(value) => setClient(value as keyof typeof clients)} + value={client} + > + <SelectTrigger id="client-select" className="w-full border border-zinc-200 bg-white!"> + <SelectValue placeholder="Select client" /> + </SelectTrigger> + <SelectContent> + {Object.entries(clients).map(([key, value]) => ( + <SelectItem key={key} value={key}> + {value} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + </div> + <div className="flex gap-4"> + <div className="relative flex-shrink-0"> + <div className="size-10 rounded-lg border-zinc-200 border bg-zinc-100 text-zinc-900 font-medium flex items-center justify-center">2</div> + <div style={{ + height: "calc(100% - 0.5rem)", + }} className="absolute left-1/2 -z-10 top-8 w-[1px] -translate-x-1/2 transform bg-neutral-200"></div> + </div> + <div className="mt-2 space-y-2"> + <p>Copy the installation command</p> + <div className="bg-white relative shadow-xs rounded-lg max-w-md text-balance py-4 px-5 border border-zinc-200"> + <p className="text-zinc-900 font-mono text-xs w-4/5"> + npx -y install-mcp@latest https://api.supermemory.ai/mcp --client {client} --oauth=yes + </p> + <Button className="absolute right-2 top-2" onClick={() => { + navigator.clipboard.writeText(`npx -y install-mcp@latest https://api.supermemory.ai/mcp --client ${client} --oauth=yes`); + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 2000); + }}> + {isCopied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />} + <TextMorph> + {isCopied ? "Copied!" : "Copy"} + </TextMorph> + </Button> + </div> + </div> + </div> + <div className="flex gap-4"> + <div className="relative flex-shrink-0"> + <div className="size-10 rounded-lg bg-zinc-100 border-zinc-200 border text-zinc-900 font-medium flex items-center justify-center">3</div> + </div> + <div className="mt-2 space-y-2 w-full"> + <p>Run the command in your terminal of choice</p> + <motion.div + className={cn("px-5 py-4 border shadow-xs rounded-lg flex items-center gap-3 font-mono text-sm")} + animate={{ + backgroundColor: isInstalling ? "rgb(250 250 250)" : "rgb(240 253 244)", // zinc-50 to green-50 + borderColor: isInstalling ? "rgb(228 228 231)" : "rgb(187 247 208)", // zinc-200 to green-200 + }} + transition={{ + duration: 0.3, + ease: "easeInOut" + }} + > + <AnimatePresence mode="wait"> + {isInstalling ? ( + <motion.div + key="loading" + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ duration: 0.2 }} + > + <LoaderIcon className="size-4 animate-spin" /> + </motion.div> + ) : ( + <motion.div + key="success" + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ duration: 0.2 }} + > + <CircleCheckIcon className="size-4 text-green-500" /> + </motion.div> + )} + </AnimatePresence> + <motion.span + key={isInstalling ? "installing" : "complete"} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ duration: 0.3, ease: "easeOut" }} + > + {isInstalling ? "Waiting for installation..." : "Installation complete!"} + </motion.span> + </motion.div> + </div> + </div> + </div> + <AnimatePresence + mode="sync"> + { + !isInstalling ? ( + <motion.div + key="save" + initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} + exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + transition={{ duration: 0.2, ease: "easeOut" }} + className="flex justify-end"> + + <Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={nextStep}> + Continue + </Button> + </motion.div> + ) : <motion.div + key="skip" + initial={{ opacity: 0, filter: "blur(5px)", }} + animate={{ opacity: 1, filter: "blur(0px)", }} + exit={{ opacity: 0, filter: "blur(5px)", }} + transition={{ duration: 0.2, ease: "easeOut" }} + className="flex justify-end"> + + <Button variant="link" size="lg" className="text-zinc-900 font-medium! text-lg underline w-fit px-0! cursor-pointer" onClick={nextStep}> + Skip For Now + </Button> + </motion.div> + } + </AnimatePresence> + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/app/onboarding/name-form.tsx b/apps/web/app/onboarding/name-form.tsx new file mode 100644 index 00000000..c112933c --- /dev/null +++ b/apps/web/app/onboarding/name-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { Button } from "@repo/ui/components/button"; +import { useOnboarding } from "./onboarding-context"; +import { useAuth } from "@lib/auth-context"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Input } from "@ui/components/input"; +import { CheckIcon } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { NavMenu } from "./nav-menu"; +import { authClient } from "@lib/auth"; + +export function NameForm() { + const { nextStep, totalSteps, getStepNumberFor } = useOnboarding(); + const { user } = useAuth(); + const [name, setName] = useState(user?.name ?? ""); + + useEffect(() => { + if (!name && user?.name) { + setName(user.name); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.name]); + + function handleNext(): void { + const trimmed = name.trim(); + if (!trimmed) { + nextStep(); + return; + } + + nextStep(); + void authClient + .updateUser({ name: trimmed }) + .catch((error: unknown) => { + console.error("Failed to update user name during onboarding:", error); + }); + } + + function handleSubmit(e: React.FormEvent): void { + e.preventDefault(); + handleNext(); + } + + if (!user) { + return ( + <div className="flex flex-col gap-6"> + <h1 className="text-4xl">You need to sign in to continue</h1> + <Link href="/login">Login</Link> + </div> + ); + } + + return ( + <div className="flex flex-col gap-4"> + <NavMenu> + <p className="text-base text-zinc-600"> + Step {getStepNumberFor("name")} of {totalSteps} + </p> + </NavMenu> + <h1 className="text-4xl"> + What should we call you? + </h1> + + <form onSubmit={handleSubmit} className="flex flex-col group"> + <div className="relative flex flex-col"> + <input type="text" autoFocus name="name" autoComplete="name" autoCorrect="off" autoCapitalize="none" spellCheck="false" className="text-black outline-0 text-2xl h-12 font-normal p-0" placeholder="John Doe" value={name} onChange={(e) => setName(e.target.value)} /> + <AnimatePresence + mode="popLayout"> + { + name && ( + <motion.div + animate={{ opacity: 1 }} + exit={{ opacity: 0 }} + initial={{ opacity: 0 }} + key="next" + transition={{ duration: 0.15 }} + className="absolute pointer-events-none inset-0 flex items-center justify-end"> + <button type="submit" className="cursor-pointer transition-colors duration-150 gap-2 pointer-events-auto flex items-center p-2 hover:bg-zinc-100 rounded-lg"> + <CheckIcon className="w-4 h-4" /> + {/* <span className="text-sm">Next</span> */} + </button> + </motion.div> + ) + } + </AnimatePresence> + </div> + <div className="w-full rounded-full group-focus-within:bg-zinc-400 transition-colors h-px bg-zinc-200"></div> + </form> + + + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/app/onboarding/nav-menu.tsx b/apps/web/app/onboarding/nav-menu.tsx new file mode 100644 index 00000000..e66187d2 --- /dev/null +++ b/apps/web/app/onboarding/nav-menu.tsx @@ -0,0 +1,44 @@ +"use client"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@ui/components/hover-card" +import { useOnboarding, type OnboardingStep } from "./onboarding-context"; +import { useState } from "react"; +import { cn } from "@lib/utils"; + +export function NavMenu({ children }: { children: React.ReactNode }) { + const { setStep, currentStep, visibleSteps, getStepNumberFor } = useOnboarding(); + const [open, setOpen] = useState(false); + const LABELS: Record<OnboardingStep, string> = { + intro: "Intro", + name: "Name", + bio: "About you", + connections: "Connections", + mcp: "MCP", + extension: "Extension", + welcome: "Welcome", + }; + const navigableSteps = visibleSteps.filter(step => step !== "intro" && step !== "welcome"); + return ( + <HoverCard openDelay={100} open={open} onOpenChange={setOpen}> + <HoverCardTrigger className="w-fit" asChild>{children}</HoverCardTrigger> + <HoverCardContent align="start" side="left" sideOffset={24} className="origin-top-right bg-white border border-zinc-200 text-zinc-900"> + <h2 className="text-zinc-900 text-sm font-medium">Go to step</h2> + <ul className="text-sm mt-2"> + {navigableSteps.map((step) => ( + <li key={step}> + <button type="button" className={cn("py-1.5 px-2 rounded-md hover:bg-zinc-100 w-full text-left", currentStep === step && "bg-zinc-100")} onClick={() => { + setStep(step); + setOpen(false); + }}> + {getStepNumberFor(step)}. {LABELS[step]} + </button> + </li> + ))} + </ul> + </HoverCardContent> + </HoverCard> + ); +}
\ No newline at end of file diff --git a/apps/web/app/onboarding/onboarding-context.tsx b/apps/web/app/onboarding/onboarding-context.tsx new file mode 100644 index 00000000..13e13139 --- /dev/null +++ b/apps/web/app/onboarding/onboarding-context.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { createContext, useContext, useState, useEffect, type ReactNode, useMemo } from "react"; +import { useQueryState } from "nuqs"; +import { useIsMobile } from "@hooks/use-mobile"; + +// Define the context interface +interface OnboardingContextType { + currentStep: OnboardingStep; + setStep: (step: OnboardingStep) => void; + nextStep: () => void; + previousStep: () => void; + totalSteps: number; + currentStepIndex: number; + // Visible-step aware helpers + visibleSteps: OnboardingStep[]; + currentVisibleStepIndex: number; + currentVisibleStepNumber: number; + getStepNumberFor: (step: OnboardingStep) => number; + introTriggers: { + first: boolean; + second: boolean; + third: boolean; + fourth: boolean; + }; + orbsRevealed: boolean; + resetIntroTriggers: () => void; +} + +// Create the context +const OnboardingContext = createContext<OnboardingContextType | undefined>(undefined); + +// Define the base step order +const BASE_STEP_ORDER = ["intro", "name", "bio", "connections", "mcp", "extension", "welcome"] as const; + +export type OnboardingStep = (typeof BASE_STEP_ORDER)[number]; + +interface OnboardingProviderProps { + children: ReactNode; + initialStep?: OnboardingStep; +} + +export function OnboardingProvider({ children, initialStep = "intro" }: OnboardingProviderProps) { + // Helper function to validate if a step is valid + const isValidStep = (step: string): step is OnboardingStep => { + return BASE_STEP_ORDER.includes(step as OnboardingStep); + }; + + const [currentStep, setCurrentStep] = useQueryState("step", { + defaultValue: initialStep, + parse: (value: string) => { + // Validate the step from URL - if invalid, use the initial step + return isValidStep(value) ? value : initialStep; + }, + serialize: (value: OnboardingStep) => value, + }); + const [orbsRevealed, setOrbsRevealed] = useState(false); + const [introTriggers, setIntroTriggers] = useState({ + first: false, + second: false, + third: false, + fourth: false + }); + const isMobile = useIsMobile(); + + // Compute visible steps based on device + const visibleSteps = useMemo(() => { + if (isMobile) { + // On mobile, hide MCP and Extension steps + return BASE_STEP_ORDER.filter(s => s !== "mcp" && s !== "extension"); + } + return [...BASE_STEP_ORDER]; + }, [isMobile]); + + // Setup intro trigger timings when on intro step + useEffect(() => { + if (currentStep !== "intro") return; + + const cleanups = [ + setTimeout(() => { + setIntroTriggers(prev => ({ ...prev, first: true })); + }, 300), + setTimeout(() => { + setIntroTriggers(prev => ({ ...prev, second: true })); + }, 2000), + setTimeout(() => { + setIntroTriggers(prev => ({ ...prev, third: true })); + }, 4000), + setTimeout(() => { + setIntroTriggers(prev => ({ ...prev, fourth: true })); + }, 5500), + ]; + + return () => cleanups.forEach(cleanup => clearTimeout(cleanup)); + }, [currentStep]); + + // Set orbs as revealed once the fourth trigger is activated OR if we're on any non-intro step + useEffect(() => { + if (currentStep !== "intro") { + // If we're not on the intro step, orbs should always be visible + // (user has either completed intro or navigated directly to another step) + if (!orbsRevealed) { + setOrbsRevealed(true); + } + } else if (introTriggers.fourth && !orbsRevealed) { + // On intro step, reveal orbs only after the fourth trigger + setOrbsRevealed(true); + } + }, [introTriggers.fourth, orbsRevealed, currentStep]); + + // Ensure current step is always part of visible steps; if not, advance to the next visible step + useEffect(() => { + if (!visibleSteps.includes(currentStep)) { + if (visibleSteps.length === 0) return; + const baseIndex = BASE_STEP_ORDER.indexOf(currentStep); + // Find the next visible step after the current base index + const nextAfterBase = visibleSteps.find(step => BASE_STEP_ORDER.indexOf(step) > baseIndex); + const targetStep = nextAfterBase ?? visibleSteps[visibleSteps.length - 1]!; + setCurrentStep(targetStep); + } + }, [visibleSteps, currentStep]); + + function setStep(step: OnboardingStep) { + setCurrentStep(step); + } + + function nextStep() { + const currentIndex = visibleSteps.indexOf(currentStep); + const nextIndex = currentIndex + 1; + + if (nextIndex < visibleSteps.length) { + setStep(visibleSteps[nextIndex]!); + } + } + + function previousStep() { + const currentIndex = visibleSteps.indexOf(currentStep); + const previousIndex = currentIndex - 1; + + if (previousIndex >= 0) { + setStep(visibleSteps[previousIndex]!); + } + } + + function resetIntroTriggers() { + setIntroTriggers({ + first: false, + second: false, + third: false, + fourth: false + }); + } + + const currentStepIndex = BASE_STEP_ORDER.indexOf(currentStep); + + // Visible-step aware helpers + const stepsForNumbering = useMemo(() => visibleSteps.filter(s => s !== "intro" && s !== "welcome"), [visibleSteps]); + + function getStepNumberFor(step: OnboardingStep): number { + if (step === "intro" || step === "welcome") { + return 0; + } + const idx = stepsForNumbering.indexOf(step); + return idx === -1 ? 0 : idx + 1; + } + + const currentVisibleStepIndex = useMemo(() => visibleSteps.indexOf(currentStep), [visibleSteps, currentStep]); + const currentVisibleStepNumber = useMemo(() => getStepNumberFor(currentStep), [currentStep, stepsForNumbering]); + const totalSteps = stepsForNumbering.length; + + const contextValue: OnboardingContextType = { + currentStep, + setStep, + nextStep, + previousStep, + totalSteps, + currentStepIndex, + visibleSteps, + currentVisibleStepIndex, + currentVisibleStepNumber, + getStepNumberFor, + introTriggers, + orbsRevealed, + resetIntroTriggers, + }; + + return ( + <OnboardingContext.Provider value={contextValue}> + {children} + </OnboardingContext.Provider> + ); +} + +export function useOnboarding() { + const context = useContext(OnboardingContext); + + if (context === undefined) { + throw new Error("useOnboarding must be used within an OnboardingProvider"); + } + + return context; +} diff --git a/apps/web/app/onboarding/onboarding-form.tsx b/apps/web/app/onboarding/onboarding-form.tsx new file mode 100644 index 00000000..962f8e47 --- /dev/null +++ b/apps/web/app/onboarding/onboarding-form.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { motion, AnimatePresence } from "motion/react"; +import { NameForm } from "./name-form"; +import { Intro } from "./intro"; +import { useOnboarding } from "./onboarding-context"; +import { BioForm } from "./bio-form"; +import { ConnectionsForm } from "./connections-form"; +import { ExtensionForm } from "./extension-form"; +import { MCPForm } from "./mcp-form"; +import { Welcome } from "./welcome"; + +export function OnboardingForm() { + const { currentStep, resetIntroTriggers } = useOnboarding(); + + return ( + <div className="text-6xl px-6 py-8 tracking-wide font-bold font-serif flex flex-col justify-center max-sm:w-full"> + <AnimatePresence mode="wait" onExitComplete={resetIntroTriggers}> + {currentStep === "intro" && ( + <motion.div + key="intro" + initial={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }} + animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} + exit={{ opacity: 0, filter: "blur(10px)", scale: 0.98 }} + transition={{ duration: 0.28, ease: "easeInOut" }} + > + <Intro /> + </motion.div> + )} + {currentStep === "name" && ( + <motion.div + key="name" + initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} + exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + transition={{ duration: 0.3, ease: "easeOut" }} + > + <NameForm /> + </motion.div> + )} + {currentStep === "bio" && ( + <motion.div + key="bio" + initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} + exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + transition={{ duration: 0.3, ease: "easeOut" }} + > + <BioForm /> + </motion.div> + )} + {currentStep === "connections" && ( + <motion.div + key="connections" + initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} + exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + transition={{ duration: 0.3, ease: "easeOut" }} + > + <ConnectionsForm /> + </motion.div> + )} + {currentStep === "mcp" && ( + <motion.div + key="mcp" + initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} + exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + transition={{ duration: 0.3, ease: "easeOut" }} + > + <MCPForm /> + </motion.div> + )} + {currentStep === "extension" && ( + <motion.div + key="extension" + initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} + exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + transition={{ duration: 0.3, ease: "easeOut" }} + > + <ExtensionForm /> + </motion.div> + )} + {currentStep === "welcome" && ( + <motion.div + key="welcome" + initial={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }} + exit={{ opacity: 0, filter: "blur(10px)", scale: 0.95 }} + transition={{ duration: 0.3, ease: "easeOut" }} + > + <Welcome /> + </motion.div> + )} + </AnimatePresence> + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/app/onboarding/page.tsx b/apps/web/app/onboarding/page.tsx new file mode 100644 index 00000000..b04f1349 --- /dev/null +++ b/apps/web/app/onboarding/page.tsx @@ -0,0 +1,28 @@ +import { getSession } from "@lib/auth"; +import { OnboardingForm } from "./onboarding-form"; +import { OnboardingProvider } from "./onboarding-context"; +import { FloatingOrbs } from "./floating-orbs"; +import { OnboardingProgressBar } from "./progress-bar"; +import { redirect } from "next/navigation"; +import { type Metadata } from "next"; + +export const metadata: Metadata = { + title: "Welcome to Supermemory", + description: "We're excited to have you on board.", +}; + +export default function OnboardingPage() { + const session = getSession(); + + if (!session) redirect("/login"); + + return ( + <div className="min-h-screen w-full overflow-x-hidden text-zinc-900 bg-white flex items-center justify-center relative"> + <OnboardingProvider> + <OnboardingProgressBar /> + <FloatingOrbs /> + <OnboardingForm /> + </OnboardingProvider> + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/app/onboarding/progress-bar.tsx b/apps/web/app/onboarding/progress-bar.tsx new file mode 100644 index 00000000..9bc01b01 --- /dev/null +++ b/apps/web/app/onboarding/progress-bar.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { motion } from "motion/react"; +import { useOnboarding } from "./onboarding-context"; + +export function OnboardingProgressBar() { + const { currentVisibleStepNumber, totalSteps } = useOnboarding(); + + const progress = totalSteps === 0 + ? 0 + : (currentVisibleStepNumber / totalSteps) * 100; + + return ( + <div className="fixed top-0 left-0 right-0 z-50 h-1 bg-zinc-200"> + <motion.div + className="h-full bg-gradient-to-r from-orange-500 via-amber-500 to-gold-600" + initial={{ width: "0%" }} + animate={{ width: `${progress}%` }} + transition={{ + duration: 0.8, + ease: "easeInOut" + }} + /> + </div> + ); +} diff --git a/apps/web/app/onboarding/welcome.tsx b/apps/web/app/onboarding/welcome.tsx new file mode 100644 index 00000000..d3b19dcd --- /dev/null +++ b/apps/web/app/onboarding/welcome.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { Button } from "@ui/components/button"; +import { ArrowRightIcon, ChevronRightIcon } from "lucide-react"; + +export function Welcome() { + return ( + <div className="flex flex-col gap-4 items-center text-center"> + <h1>Welcome to Supermemory</h1> + <p className="text-zinc-600 text-2xl"> + We're excited to have you on board. + </p> + + <a href="/" className="tracking-normal w-fit flex items-center justify-center text-2xl underline cursor-pointer font-medium text-zinc-900"> + Get started + <ArrowRightIcon className="size-4 ml-2" /> + </a> + </div> + ); +}
\ No newline at end of file diff --git a/apps/web/components/text-effect.tsx b/apps/web/components/text-effect.tsx new file mode 100644 index 00000000..82c6823c --- /dev/null +++ b/apps/web/components/text-effect.tsx @@ -0,0 +1,294 @@ +'use client'; +import { cn } from '@lib/utils'; +import { + AnimatePresence, + motion +} from 'motion/react'; +import type { + TargetAndTransition, + Transition, + Variant, + Variants, +} from 'motion/react' +import React from 'react'; + +export type PresetType = 'blur' | 'fade-in-blur' | 'scale' | 'fade' | 'slide'; + +export type PerType = 'word' | 'char' | 'line'; + +export type TextEffectProps = { + children: string; + per?: PerType; + as?: keyof React.JSX.IntrinsicElements; + variants?: { + container?: Variants; + item?: Variants; + }; + className?: string; + preset?: PresetType; + delay?: number; + speedReveal?: number; + speedSegment?: number; + trigger?: boolean; + onAnimationComplete?: () => void; + onAnimationStart?: () => void; + segmentWrapperClassName?: string; + containerTransition?: Transition; + segmentTransition?: Transition; + style?: React.CSSProperties; +}; + +const defaultStaggerTimes: Record<PerType, number> = { + char: 0.03, + word: 0.05, + line: 0.1, +}; + +const defaultContainerVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.05, + }, + }, + exit: { + transition: { staggerChildren: 0.05, staggerDirection: -1 }, + }, +}; + +const defaultItemVariants: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + }, + exit: { opacity: 0 }, +}; + +const presetVariants: Record< + PresetType, + { container: Variants; item: Variants } +> = { + blur: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, filter: 'blur(12px)' }, + visible: { opacity: 1, filter: 'blur(0px)' }, + exit: { opacity: 0, filter: 'blur(12px)' }, + }, + }, + 'fade-in-blur': { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, y: 20, filter: 'blur(12px)' }, + visible: { opacity: 1, y: 0, filter: 'blur(0px)' }, + exit: { opacity: 0, y: 20, filter: 'blur(12px)' }, + }, + }, + scale: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, scale: 0 }, + visible: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0 }, + }, + }, + fade: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, + exit: { opacity: 0 }, + }, + }, + slide: { + container: defaultContainerVariants, + item: { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: 20 }, + }, + }, +}; + +const AnimationComponent: React.FC<{ + segment: string; + variants: Variants; + per: 'line' | 'word' | 'char'; + segmentWrapperClassName?: string; +}> = React.memo(({ segment, variants, per, segmentWrapperClassName }) => { + const content = + per === 'line' ? ( + <motion.span variants={variants} className='block'> + {segment} + </motion.span> + ) : per === 'word' ? ( + <motion.span + aria-hidden='true' + variants={variants} + className='inline-block whitespace-pre' + > + {segment} + </motion.span> + ) : ( + <motion.span className='inline-block whitespace-pre'> + {segment.split('').map((char, charIndex) => ( + <motion.span + key={`char-${charIndex}`} + aria-hidden='true' + variants={variants} + className='inline-block whitespace-pre' + > + {char} + </motion.span> + ))} + </motion.span> + ); + + if (!segmentWrapperClassName) { + return content; + } + + const defaultWrapperClassName = per === 'line' ? 'block' : 'inline-block'; + + return ( + <span className={cn(defaultWrapperClassName, segmentWrapperClassName)}> + {content} + </span> + ); +}); + +AnimationComponent.displayName = 'AnimationComponent'; + +const splitText = (text: string, per: PerType) => { + if (per === 'line') return text.split('\n'); + return text.split(/(\s+)/); +}; + +const hasTransition = ( + variant?: Variant +): variant is TargetAndTransition & { transition?: Transition } => { + if (!variant) return false; + return ( + typeof variant === 'object' && 'transition' in variant + ); +}; + +const createVariantsWithTransition = ( + baseVariants: Variants, + transition?: Transition & { exit?: Transition } +): Variants => { + if (!transition) return baseVariants; + + const { exit: _, ...mainTransition } = transition; + + return { + ...baseVariants, + visible: { + ...baseVariants.visible, + transition: { + ...(hasTransition(baseVariants.visible) + ? baseVariants.visible.transition + : {}), + ...mainTransition, + }, + }, + exit: { + ...baseVariants.exit, + transition: { + ...(hasTransition(baseVariants.exit) + ? baseVariants.exit.transition + : {}), + ...mainTransition, + staggerDirection: -1, + }, + }, + }; +}; + +export function TextEffect({ + children, + per = 'word', + as = 'p', + variants, + className, + preset = 'fade', + delay = 0, + speedReveal = 1, + speedSegment = 1, + trigger = true, + onAnimationComplete, + onAnimationStart, + segmentWrapperClassName, + containerTransition, + segmentTransition, + style, +}: TextEffectProps) { + const segments = splitText(children, per); + const MotionTag = motion[as as keyof typeof motion] as typeof motion.div; + + const baseVariants = preset + ? presetVariants[preset] + : { container: defaultContainerVariants, item: defaultItemVariants }; + + const stagger = defaultStaggerTimes[per] / speedReveal; + + const baseDuration = 0.3 / speedSegment; + + const customStagger = hasTransition(variants?.container?.visible ?? {}) + ? (variants?.container?.visible as TargetAndTransition).transition + ?.staggerChildren + : undefined; + + const customDelay = hasTransition(variants?.container?.visible ?? {}) + ? (variants?.container?.visible as TargetAndTransition).transition + ?.delayChildren + : undefined; + + const computedVariants = { + container: createVariantsWithTransition( + variants?.container || baseVariants.container, + { + staggerChildren: customStagger ?? stagger, + delayChildren: customDelay ?? delay, + ...containerTransition, + exit: { + staggerChildren: customStagger ?? stagger, + staggerDirection: -1, + }, + } + ), + item: createVariantsWithTransition(variants?.item || baseVariants.item, { + duration: baseDuration, + ...segmentTransition, + }), + }; + + return ( + <AnimatePresence mode='popLayout'> + {trigger && ( + <MotionTag + initial='hidden' + animate='visible' + exit='exit' + variants={computedVariants.container} + className={className} + onAnimationComplete={onAnimationComplete} + onAnimationStart={onAnimationStart} + style={style} + > + {per !== 'line' ? <span className='sr-only'>{children}</span> : null} + {segments.map((segment, index) => ( + <AnimationComponent + key={`${per}-${index}-${segment}`} + segment={segment} + variants={computedVariants.item} + per={per} + segmentWrapperClassName={segmentWrapperClassName} + /> + ))} + </MotionTag> + )} + </AnimatePresence> + ); +} diff --git a/apps/web/components/text-morph.tsx b/apps/web/components/text-morph.tsx new file mode 100644 index 00000000..467dc999 --- /dev/null +++ b/apps/web/components/text-morph.tsx @@ -0,0 +1,74 @@ +'use client'; +import { cn } from '@lib/utils'; +import { AnimatePresence, motion, type Transition, type Variants } from 'motion/react'; +import { useMemo, useId } from 'react'; + +export type TextMorphProps = { + children: string; + as?: React.ElementType; + className?: string; + style?: React.CSSProperties; + variants?: Variants; + transition?: Transition; +}; + +export function TextMorph({ + children, + as: Component = 'p', + className, + style, + variants, + transition, +}: TextMorphProps) { + const uniqueId = useId(); + + const characters = useMemo(() => { + const charCounts: Record<string, number> = {}; + + return children.split('').map((char) => { + const lowerChar = char.toLowerCase(); + charCounts[lowerChar] = (charCounts[lowerChar] || 0) + 1; + + return { + id: `${uniqueId}-${lowerChar}${charCounts[lowerChar]}`, + label: char === ' ' ? '\u00A0' : char, + }; + }); + }, [children, uniqueId]); + + const defaultVariants: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, + }; + + const defaultTransition: Transition = { + type: 'spring', + stiffness: 280, + damping: 18, + mass: 0.3, + }; + + return ( + // @ts-expect-error - style is optional + <Component className={cn(className)} aria-label={children} style={style}> + <AnimatePresence mode='popLayout' initial={false}> + {characters.map((character) => ( + <motion.span + key={character.id} + layoutId={character.id} + className='inline-block' + aria-hidden='true' + initial='initial' + animate='animate' + exit='exit' + variants={variants || defaultVariants} + transition={transition || defaultTransition} + > + {character.label} + </motion.span> + ))} + </AnimatePresence> + </Component> + ); +} diff --git a/apps/web/package.json b/apps/web/package.json index 54d69020..057532f4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,13 +57,14 @@ "dotenv": "^16.6.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", + "framer-motion": "^12.23.12", "is-hotkey": "^0.2.0", "isbot": "^5.1.28", "lucide-react": "^0.525.0", "motion": "^12.19.2", "next": "15.3.0", "next-themes": "^0.4.6", - "nuqs": "^2.4.3", + "nuqs": "^2.5.2", "posthog-js": "^1.257.0", "random-word-slugs": "^0.1.7", "react": "^19.1.0", diff --git a/apps/web/public/images/gdrive.svg b/apps/web/public/images/gdrive.svg new file mode 100644 index 00000000..a8cefd5b --- /dev/null +++ b/apps/web/public/images/gdrive.svg @@ -0,0 +1,8 @@ +<svg viewBox="0 0 87.3 78" xmlns="http://www.w3.org/2000/svg"> + <path d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z" fill="#0066da"/> + <path d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z" fill="#00ac47"/> + <path d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z" fill="#ea4335"/> + <path d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z" fill="#00832d"/> + <path d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z" fill="#2684fc"/> + <path d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z" fill="#ffba00"/> +</svg>
\ No newline at end of file diff --git a/apps/web/public/images/icon-16.png b/apps/web/public/images/icon-16.png Binary files differnew file mode 100644 index 00000000..549d267d --- /dev/null +++ b/apps/web/public/images/icon-16.png diff --git a/apps/web/public/images/notion.svg b/apps/web/public/images/notion.svg new file mode 100644 index 00000000..bf6442f7 --- /dev/null +++ b/apps/web/public/images/notion.svg @@ -0,0 +1,4 @@ +<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M6.017 4.313l55.333 -4.087c6.797 -0.583 8.543 -0.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277 -1.553 6.807 -6.99 7.193L24.467 99.967c-4.08 0.193 -6.023 -0.39 -8.16 -3.113L3.3 79.94c-2.333 -3.113 -3.3 -5.443 -3.3 -8.167V11.113c0 -3.497 1.553 -6.413 6.017 -6.8z" fill="#fff"/> + <path fill-rule="evenodd" clip-rule="evenodd" d="M61.35 0.227l-55.333 4.087C1.553 4.7 0 7.617 0 11.113v60.66c0 2.723 0.967 5.053 3.3 8.167l13.007 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257 -3.89c5.433 -0.387 6.99 -2.917 6.99 -7.193V20.64c0 -2.21 -0.873 -2.847 -3.443 -4.733L74.167 3.143c-4.273 -3.107 -6.02 -3.5 -12.817 -2.917zM25.92 19.523c-5.247 0.353 -6.437 0.433 -9.417 -1.99L8.927 11.507c-0.77 -0.78 -0.383 -1.753 1.557 -1.947l53.193 -3.887c4.467 -0.39 6.793 1.167 8.54 2.527l9.123 6.61c0.39 0.197 1.36 1.36 0.193 1.36l-54.933 3.307 -0.68 0.047zM19.803 88.3V30.367c0 -2.53 0.777 -3.697 3.103 -3.893L86 22.78c2.14 -0.193 3.107 1.167 3.107 3.693v57.547c0 2.53 -0.39 4.67 -3.883 4.863l-60.377 3.5c-3.493 0.193 -5.043 -0.97 -5.043 -4.083zm59.6 -54.827c0.387 1.75 0 3.5 -1.75 3.7l-2.91 0.577v42.773c-2.527 1.36 -4.853 2.137 -6.797 2.137 -3.107 0 -3.883 -0.973 -6.21 -3.887l-19.03 -29.94v28.967l6.02 1.363s0 3.5 -4.857 3.5l-13.39 0.777c-0.39 -0.78 0 -2.723 1.357 -3.11l3.497 -0.97v-38.3L30.48 40.667c-0.39 -1.75 0.58 -4.277 3.3 -4.473l14.367 -0.967 19.8 30.327v-26.83l-5.047 -0.58c-0.39 -2.143 1.163 -3.7 3.103 -3.89l13.4 -0.78z" fill="#000"/> +</svg> diff --git a/apps/web/public/images/onedrive.svg b/apps/web/public/images/onedrive.svg new file mode 100644 index 00000000..f7d7a6a6 --- /dev/null +++ b/apps/web/public/images/onedrive.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 5.5 32 20.5"><title>OfficeCore10_32x_24x_20x_16x_01-22-2019</title><g id="STYLE_COLOR"><path d="M12.20245,11.19292l.00031-.0011,6.71765,4.02379,4.00293-1.68451.00018.00068A6.4768,6.4768,0,0,1,25.5,13c.14764,0,.29358.0067.43878.01639a10.00075,10.00075,0,0,0-18.041-3.01381C7.932,10.00215,7.9657,10,8,10A7.96073,7.96073,0,0,1,12.20245,11.19292Z" fill="#0364b8"/><path d="M12.20276,11.19182l-.00031.0011A7.96073,7.96073,0,0,0,8,10c-.0343,0-.06805.00215-.10223.00258A7.99676,7.99676,0,0,0,1.43732,22.57277l5.924-2.49292,2.63342-1.10819,5.86353-2.46746,3.06213-1.28859Z" fill="#0078d4"/><path d="M25.93878,13.01639C25.79358,13.0067,25.64764,13,25.5,13a6.4768,6.4768,0,0,0-2.57648.53178l-.00018-.00068-4.00293,1.68451,1.16077.69528L23.88611,18.19l1.66009.99438,5.67633,3.40007a6.5002,6.5002,0,0,0-5.28375-9.56805Z" fill="#1490df"/><path d="M25.5462,19.18437,23.88611,18.19l-3.80493-2.2791-1.16077-.69528L15.85828,16.5042,9.99475,18.97166,7.36133,20.07985l-5.924,2.49292A7.98889,7.98889,0,0,0,8,26H25.5a6.49837,6.49837,0,0,0,5.72253-3.41556Z" fill="#28a8ea"/></g></svg>
\ No newline at end of file |