├── .github └── FUNDING.yml ├── src ├── vite-env.d.ts ├── providers │ ├── theme-provider.tsx │ ├── toaster.tsx │ └── halloween-provider.tsx ├── components │ ├── logo.tsx │ ├── ui │ │ ├── avatar.tsx │ │ ├── input.tsx │ │ ├── toggle.tsx │ │ ├── button.tsx │ │ ├── tooltip.tsx │ │ ├── modal.tsx │ │ ├── dropdown.tsx │ │ ├── accordion.tsx │ │ └── toast.tsx │ ├── EmptyState.tsx │ ├── ErrorBoundary.tsx │ ├── theme-switcher.tsx │ ├── ErrorMessage.tsx │ ├── halloween-switcher.tsx │ ├── UserCursor.tsx │ ├── lander │ │ ├── footer.tsx │ │ ├── preview.tsx │ │ ├── selfhost.tsx │ │ ├── faqs.tsx │ │ ├── hero.tsx │ │ ├── nav.tsx │ │ └── pricing-beta.tsx │ ├── halloween-decorations.tsx │ ├── privacy.tsx │ ├── HelpModal.tsx │ ├── terms.tsx │ ├── FeedbackModal.tsx │ └── UserStack.tsx ├── libs │ └── utils.ts ├── authenticated │ ├── ToolsBar │ │ ├── index.tsx │ │ └── NoteButton.tsx │ ├── LoadingIndicator.tsx │ ├── Onboarding.tsx │ ├── IconButton.tsx │ ├── index.tsx │ ├── NoteControls.tsx │ ├── Note.tsx │ └── Board.tsx ├── auth │ ├── signin.tsx │ └── signup.tsx ├── main.tsx ├── hooks │ ├── useConvexAuth.ts │ ├── usePresence.ts │ └── use-toast.tsx ├── index.css ├── App.tsx └── assets │ └── react.svg ├── tsconfig.node.tsbuildinfo ├── .gitattributes ├── public ├── board.png ├── duct-tape.png ├── robots.txt ├── sticky-sad.png ├── sticky-logo.png ├── sitemap.xml └── vite.svg ├── convex.json ├── postcss.config.js ├── vercel.json ├── tsconfig.json ├── convex ├── auth.config.ts ├── convex.config.ts ├── _generated │ ├── api.js │ ├── dataModel.d.ts │ ├── server.js │ ├── api.d.ts │ └── server.d.ts ├── support.ts ├── tsconfig.json ├── boardSharing.ts ├── emails.ts ├── schema.ts ├── notes.ts ├── README.md ├── welcomeEmail.tsx ├── presence.ts ├── users.ts └── boards.ts ├── .template.env ├── vite.config.ts ├── .gitignore ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── LICENSE ├── tsconfig.app.tsbuildinfo ├── tailwind.config.js ├── package.json ├── index.html └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [hamzasaleem2] 2 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.node.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./vite.config.ts"],"version":"5.6.2"} -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /public/board.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamzasaleem2/sticky/HEAD/public/board.png -------------------------------------------------------------------------------- /public/duct-tape.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamzasaleem2/sticky/HEAD/public/duct-tape.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://sticky.today/sitemap.xml -------------------------------------------------------------------------------- /public/sticky-sad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamzasaleem2/sticky/HEAD/public/sticky-sad.png -------------------------------------------------------------------------------- /public/sticky-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hamzasaleem2/sticky/HEAD/public/sticky-logo.png -------------------------------------------------------------------------------- /convex.json: -------------------------------------------------------------------------------- 1 | { 2 | "node": { 3 | "externalPackages": [ 4 | "resend" 5 | ] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | } 6 | } -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/index.html" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /convex/auth.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: process.env.CLERK_JWT_ISSUER_DOMAIN, 5 | applicationID: "convex", 6 | }, 7 | ] 8 | }; -------------------------------------------------------------------------------- /.template.env: -------------------------------------------------------------------------------- 1 | # Deployment used by `npx convex dev` 2 | CONVEX_DEPLOYMENT= 3 | 4 | VITE_CONVEX_URL= 5 | 6 | VITE_CLERK_PUBLISHABLE_KEY= 7 | 8 | VITE_POSTHOG_KEY= 9 | VITE_POSTHOG_API_HOST= 10 | -------------------------------------------------------------------------------- /convex/convex.config.ts: -------------------------------------------------------------------------------- 1 | import { defineApp } from "convex/server"; 2 | import aggregate from "@convex-dev/aggregate/convex.config.js"; 3 | 4 | const app = defineApp(); 5 | app.use(aggregate, { name: "aggregateBoardsByUser" }); 6 | export default app; -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import { resolve } from 'path'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | '@': resolve(__dirname, 'src'), 11 | }, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /src/providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ThemeProvider as NextThemesProvider } from 'next-themes' 4 | import { type ThemeProviderProps } from 'next-themes/dist/types' 5 | 6 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 7 | return {children} 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | Dockerfile 27 | -------------------------------------------------------------------------------- /src/components/logo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LazyLoadImage } from 'react-lazy-load-image-component'; 3 | import 'react-lazy-load-image-component/src/effects/blur.css'; 4 | 5 | interface LogoProps { 6 | className?: string; 7 | } 8 | 9 | const Logo: React.FC = ({ className }) => { 10 | return ( 11 | 17 | ); 18 | }; 19 | 20 | export default Logo; -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { ClassValue } from 'clsx' 2 | 3 | import { cn } from '../../libs/utils' 4 | 5 | export default function Avatar({ 6 | className, 7 | imageUrl, 8 | }: { 9 | className?: ClassValue 10 | imageUrl: string 11 | }) { 12 | return ( 13 |
22 | ) 23 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* prettier-ignore-start */ 2 | 3 | /* eslint-disable */ 4 | /** 5 | * Generated `api` utility. 6 | * 7 | * THIS CODE IS AUTOMATICALLY GENERATED. 8 | * 9 | * To regenerate, run `npx convex dev`. 10 | * @module 11 | */ 12 | 13 | import { anyApi, componentsGeneric } from "convex/server"; 14 | 15 | /** 16 | * A utility for referencing Convex functions in your app's API. 17 | * 18 | * Usage: 19 | * ```js 20 | * const myFunctionReference = api.myModule.myFunction; 21 | * ``` 22 | */ 23 | export const api = anyApi; 24 | export const internal = anyApi; 25 | export const components = componentsGeneric(); 26 | 27 | /* prettier-ignore-end */ 28 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src", "convex/emails"] 24 | } 25 | -------------------------------------------------------------------------------- /convex/support.ts: -------------------------------------------------------------------------------- 1 | import { mutation } from './_generated/server' 2 | import { v } from 'convex/values' 3 | 4 | export const supportRequest = mutation({ 5 | args: { input: v.string() }, 6 | handler: async (ctx, args) => { 7 | const identity = await ctx.auth.getUserIdentity(); 8 | if (!identity) { 9 | throw new Error("Not authenticated"); 10 | } 11 | const user = await ctx.db 12 | .query("users") 13 | .withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier.split('|')[1])) 14 | .unique(); 15 | if (!user) { 16 | throw new Error("User not found"); 17 | } 18 | await ctx.db.insert('supportRequest', { 19 | userId: user._id, 20 | input: args.input, 21 | }) 22 | }, 23 | }) -------------------------------------------------------------------------------- /src/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from './ui/button'; 3 | 4 | interface EmptyStateProps { 5 | message: string; 6 | buttonText?: string; 7 | onButtonClick?: () => void; 8 | } 9 | 10 | const EmptyState: React.FC = ({ message, buttonText, onButtonClick }) => { 11 | return ( 12 |
13 | Sad Sticky 14 |

{message}

15 | {buttonText && onButtonClick && ( 16 | 17 | )} 18 |
19 | ); 20 | }; 21 | 22 | export default EmptyState; -------------------------------------------------------------------------------- /src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component, ReactNode } from "react"; 2 | import ErrorMessage from "./ErrorMessage"; 3 | 4 | interface Props { 5 | children?: ReactNode; 6 | } 7 | 8 | interface State { 9 | hasError: boolean; 10 | error: Error | null; 11 | } 12 | 13 | class ErrorBoundary extends Component { 14 | public state: State = { 15 | hasError: false, 16 | error: null, 17 | }; 18 | 19 | public static getDerivedStateFromError(error: Error): State { 20 | return { hasError: true, error }; 21 | } 22 | 23 | public render() { 24 | if (this.state.hasError) { 25 | return ; 26 | } 27 | 28 | return this.props.children; 29 | } 30 | } 31 | 32 | export default ErrorBoundary; -------------------------------------------------------------------------------- /src/libs/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export function formatLastModified(timestamp: number): string { 9 | const date = new Date(timestamp); 10 | const now = new Date(); 11 | const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); 12 | 13 | if (diffInSeconds < 60) return 'Just now'; 14 | if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; 15 | if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; 16 | if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`; 17 | 18 | return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); 19 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "allowJs": true, 9 | "strict": true, 10 | "moduleResolution": "Bundler", 11 | "jsx": "react-jsx", 12 | "skipLibCheck": true, 13 | "allowSyntheticDefaultImports": true, 14 | 15 | /* These compiler options are required by Convex */ 16 | "target": "ESNext", 17 | "lib": ["ES2021", "dom"], 18 | "forceConsistentCasingInFileNames": true, 19 | "module": "ESNext", 20 | "isolatedModules": true, 21 | "noEmit": true 22 | }, 23 | "include": ["./**/*"], 24 | "exclude": ["./_generated"] 25 | } 26 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import { ClassValue } from 'clsx' 2 | import { cn } from '../../libs/utils' 3 | 4 | 5 | type Props = { 6 | className?: ClassValue 7 | value: string 8 | onChange: any; 9 | placeholder: string 10 | } 11 | 12 | export default function Input({ 13 | className, 14 | value, 15 | onChange, 16 | placeholder, 17 | }: Props) { 18 | return ( 19 | 31 | ) 32 | } -------------------------------------------------------------------------------- /src/components/theme-switcher.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Moon, Sun, Monitor } from 'lucide-react' 4 | import { useTheme } from 'next-themes' 5 | 6 | export function ThemeSwitcher() { 7 | const { setTheme, theme } = useTheme() 8 | 9 | return ( 10 | 19 | ) 20 | } -------------------------------------------------------------------------------- /src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Logo from './logo'; 3 | import { Button } from './ui/button'; 4 | import { useNavigate } from 'react-router-dom'; 5 | 6 | interface ErrorMessageProps { 7 | message: string; 8 | } 9 | 10 | const ErrorMessage: React.FC = ({ message }) => { 11 | const navigate = useNavigate(); 12 | return ( 13 |
14 | 15 |

Oops! Something went wrong

16 |

{message}

17 | 20 |
21 | ); 22 | }; 23 | 24 | export default ErrorMessage; -------------------------------------------------------------------------------- /src/providers/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "../hooks/use-toast" 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "../components/ui/toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/authenticated/ToolsBar/index.tsx: -------------------------------------------------------------------------------- 1 | import NoteButton from "./NoteButton"; 2 | 3 | interface Props { 4 | onCreate: () => void; 5 | currentTool: 'note' | null; 6 | onDeselectTool: () => void; 7 | } 8 | 9 | const ToolsBar: React.FC = ({ onCreate, currentTool, onDeselectTool }) => { 10 | return ( 11 |
e.stopPropagation()} 14 | > 15 |
16 | currentTool === 'note' ? onDeselectTool() : onCreate()} 19 | currentTool={currentTool} 20 | /> 21 |
22 |
23 | ); 24 | } 25 | 26 | export default ToolsBar; -------------------------------------------------------------------------------- /src/authenticated/ToolsBar/NoteButton.tsx: -------------------------------------------------------------------------------- 1 | import IconButton from "../IconButton"; 2 | 3 | type Props = { 4 | isActive: boolean; 5 | onClick: () => void; 6 | currentTool: 'note' | null; 7 | }; 8 | 9 | export default function NoteButton({ isActive, onClick }: Props) { 10 | return ( 11 | 12 | 20 | 21 | 22 | 23 | ); 24 | } -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://sticky.today/ 5 | weekly 6 | 1.0 7 | 8 | 9 | https://sticky.today/signin 10 | monthly 11 | 0.8 12 | 13 | 14 | https://sticky.today/signup 15 | monthly 16 | 0.8 17 | 18 | 19 | https://sticky.today/terms 20 | yearly 21 | 0.5 22 | 23 | 24 | https://sticky.today/privacy 25 | yearly 26 | 0.5 27 | 28 | 29 | https://sticky.today/boards 30 | daily 31 | 0.9 32 | 33 | -------------------------------------------------------------------------------- /src/authenticated/LoadingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import Logo from '../components/logo'; 2 | import { useHalloween } from '../providers/halloween-provider'; 3 | import { Ghost, Loader } from 'lucide-react'; 4 | 5 | export const LoadingIndicator = () => { 6 | const { isHalloweenMode } = useHalloween(); 7 | 8 | return ( 9 |
10 | {isHalloweenMode ? ( 11 |
12 | 13 |

Summoning your boards...

14 |
15 | ) : ( 16 |
17 | 18 | 19 |
20 | )} 21 |
22 | ); 23 | }; -------------------------------------------------------------------------------- /src/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | export default function ToggleSwitch({ 2 | isToggled, 3 | setIsToggled, 4 | }: { 5 | isToggled: boolean 6 | setIsToggled: React.Dispatch> 7 | }) { 8 | return ( 9 | 21 | ) 22 | } -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ClassValue } from 'clsx' 4 | import { cn } from '../../libs/utils' 5 | 6 | type Props = { 7 | className?: ClassValue 8 | children: React.ReactNode 9 | onClick: (event: React.MouseEvent) => void 10 | size?: 'sm' | 'default' 11 | } 12 | 13 | export function Button({ className, children, onClick, size = 'default' }: Props) { 14 | const sizeClasses = { 15 | sm: 'px-2 py-1 text-xs', 16 | default: 'px-4 py-2 text-sm' 17 | } 18 | 19 | return ( 20 | 32 | ) 33 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 hamzasaleem2 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/components/halloween-switcher.tsx: -------------------------------------------------------------------------------- 1 | import { useHalloween } from '../providers/halloween-provider'; 2 | import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; 3 | 4 | export function HalloweenSwitcher() { 5 | const { isHalloweenMode, toggleHalloweenMode } = useHalloween(); 6 | 7 | return ( 8 | 9 | 10 | 11 | 24 | 25 | 26 |

{isHalloweenMode ? 'Disable' : 'Enable'} Halloween mode

27 |
28 |
29 |
30 | ); 31 | } -------------------------------------------------------------------------------- /src/authenticated/Onboarding.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useUser } from '@clerk/clerk-react'; 3 | import { useNavigate, useLocation } from 'react-router-dom'; 4 | import { useMutation } from 'convex/react'; 5 | import { api } from '../../convex/_generated/api'; 6 | import Logo from '../components/logo'; 7 | 8 | const Onboarding: React.FC = () => { 9 | const { user } = useUser(); 10 | const navigate = useNavigate(); 11 | const location = useLocation(); 12 | const searchParams = new URLSearchParams(location.search); 13 | const selectedPlan = searchParams.get('plan') || 'free'; 14 | 15 | const storePlan = useMutation(api.users.storePlan); 16 | 17 | useEffect(() => { 18 | const saveUserPlan = async () => { 19 | if (user) { 20 | await storePlan({ plan: selectedPlan }); 21 | navigate('/boards'); 22 | } 23 | }; 24 | 25 | saveUserPlan(); 26 | }, [user, selectedPlan, storePlan, navigate]); 27 | 28 | return ( 29 |
30 | 31 |

Setting up your stickies...

32 |
33 | ); 34 | }; 35 | 36 | export default Onboarding; -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import * as TooltipPrimitive from '@radix-ui/react-tooltip' 4 | 5 | import * as React from 'react' 6 | 7 | import { cn } from '../../libs/utils' 8 | 9 | const TooltipProvider = TooltipPrimitive.Provider 10 | 11 | const Tooltip = TooltipPrimitive.Root 12 | 13 | const TooltipTrigger = TooltipPrimitive.Trigger 14 | 15 | const TooltipContent = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, sideOffset = 4, ...props }, ref) => ( 19 | 28 | )) 29 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 30 | 31 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } -------------------------------------------------------------------------------- /src/auth/signin.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/clerk-react"; 2 | import Logo from "../components/logo"; 3 | import { dark } from "@clerk/themes"; 4 | import { useState, useEffect } from "react"; 5 | import posthog from "posthog-js"; 6 | 7 | function Signin() { 8 | const [isLoading, setIsLoading] = useState(true); 9 | 10 | useEffect(() => { 11 | const timer = setTimeout(() => { 12 | setIsLoading(false); 13 | }, 500); 14 | 15 | return () => clearTimeout(timer); 16 | }, []); 17 | 18 | if (isLoading) { 19 | return ( 20 |
21 | 22 |
23 | ); 24 | } 25 | posthog.capture('signin page', { property: 'visit' }) 26 | return ( 27 |
28 |
29 | 37 |
38 |
39 | ); 40 | } 41 | 42 | export default Signin; -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.app.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/auth/signin.tsx","./src/auth/signup.tsx","./src/authenticated/board.tsx","./src/authenticated/boardslist.tsx","./src/authenticated/iconbutton.tsx","./src/authenticated/note.tsx","./src/authenticated/notecontrols.tsx","./src/authenticated/onboarding.tsx","./src/authenticated/index.tsx","./src/authenticated/toolsbar/notebutton.tsx","./src/authenticated/toolsbar/index.tsx","./src/components/emptystate.tsx","./src/components/errorboundary.tsx","./src/components/errormessage.tsx","./src/components/feedbackmodal.tsx","./src/components/helpmodal.tsx","./src/components/mobilemessage.tsx","./src/components/usercursor.tsx","./src/components/userstack.tsx","./src/components/logo.tsx","./src/components/privacy.tsx","./src/components/terms.tsx","./src/components/theme-switcher.tsx","./src/components/lander/faqs.tsx","./src/components/lander/footer.tsx","./src/components/lander/hero.tsx","./src/components/lander/nav.tsx","./src/components/lander/preview.tsx","./src/components/lander/pricing-beta.tsx","./src/components/ui/accordion.tsx","./src/components/ui/avatar.tsx","./src/components/ui/button.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/input.tsx","./src/components/ui/modal.tsx","./src/components/ui/toast.tsx","./src/components/ui/toggle.tsx","./src/hooks/use-toast.tsx","./src/hooks/useconvexauth.ts","./src/hooks/usepresence.ts","./src/libs/utils.ts","./src/providers/theme-provider.tsx","./src/providers/toaster.tsx"],"version":"5.6.2"} -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import "./index.css"; 5 | import { ConvexReactClient } from "convex/react"; 6 | import { ThemeProvider } from "./providers/theme-provider"; 7 | import { ClerkProvider, useAuth } from "@clerk/clerk-react"; 8 | import { ConvexProviderWithClerk } from "convex/react-clerk"; 9 | import { PostHogProvider } from 'posthog-js/react' 10 | import { Toaster } from "./providers/toaster"; 11 | import { HalloweenProvider } from "./providers/halloween-provider"; 12 | import HalloweenDecorations from "./components/halloween-decorations"; 13 | 14 | const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string); 15 | 16 | const options = { 17 | api_host: import.meta.env.VITE_POSTHOG_API_HOST, 18 | } 19 | 20 | ReactDOM.createRoot(document.getElementById("root")!).render( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | , 36 | ); -------------------------------------------------------------------------------- /src/providers/halloween-provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useState } from 'react'; 2 | 3 | interface HalloweenContextType { 4 | isHalloweenMode: boolean; 5 | toggleHalloweenMode: () => void; 6 | } 7 | 8 | const HalloweenContext = createContext(undefined); 9 | 10 | export function HalloweenProvider({ children }: { children: React.ReactNode }) { 11 | const [isHalloweenMode, setIsHalloweenMode] = useState(() => { 12 | const savedMode = localStorage.getItem('halloweenMode'); 13 | 14 | if (savedMode !== null) { 15 | return savedMode === 'true'; 16 | } 17 | 18 | const OCTOBER = 9; 19 | const isOctober = new Date().getMonth() === OCTOBER; 20 | 21 | localStorage.setItem('halloweenMode', String(isOctober)); 22 | return isOctober; 23 | }); 24 | const toggleHalloweenMode = () => { 25 | setIsHalloweenMode(prev => { 26 | const newValue = !prev; 27 | localStorage.setItem('halloweenMode', String(newValue)); 28 | return newValue; 29 | }); 30 | }; 31 | 32 | return ( 33 | 34 | {children} 35 | 36 | ); 37 | } 38 | 39 | export const useHalloween = () => { 40 | const context = useContext(HalloweenContext); 41 | if (context === undefined) { 42 | throw new Error('useHalloween must be used within a HalloweenProvider'); 43 | } 44 | return context; 45 | }; -------------------------------------------------------------------------------- /src/hooks/useConvexAuth.ts: -------------------------------------------------------------------------------- 1 | import { useAuth, useUser } from "@clerk/clerk-react"; 2 | import { api } from "../../convex/_generated/api"; 3 | import { useEffect, useState } from "react"; 4 | import { useMutation } from "convex/react"; 5 | 6 | export function useConvexAuth() { 7 | const { isLoaded: clerkLoaded, isSignedIn } = useAuth(); 8 | const { user } = useUser(); 9 | const [isLoaded, setIsLoaded] = useState(false); 10 | 11 | const createOrUpdateUser = useMutation(api.users.createOrUpdateUser); 12 | 13 | useEffect(() => { 14 | if (!clerkLoaded) return; 15 | 16 | if (isSignedIn && user) { 17 | const userName = getUserName(user); 18 | createOrUpdateUser({ 19 | tokenIdentifier: user.id, 20 | name: userName, 21 | email: user.primaryEmailAddress?.emailAddress, 22 | profileImageUrl: user.imageUrl, 23 | }).then(() => setIsLoaded(true)); 24 | } else { 25 | setIsLoaded(true); 26 | } 27 | }, [clerkLoaded, isSignedIn, user, createOrUpdateUser]); 28 | 29 | return { 30 | isLoaded: isLoaded && clerkLoaded, 31 | isAuthenticated: isSignedIn, 32 | user: isSignedIn ? user : null, 33 | }; 34 | } 35 | 36 | function getUserName(user: any): string { 37 | if (user.fullName) return user.fullName; 38 | if (user.firstName && user.lastName) return `${user.firstName} ${user.lastName}`; 39 | if (user.firstName) return user.firstName; 40 | if (user.username) return user.username; 41 | if (user.emailAddresses && user.emailAddresses.length > 0) { 42 | return user.emailAddresses[0].emailAddress.split('@')[0]; 43 | } 44 | return "Anonymous User"; 45 | } -------------------------------------------------------------------------------- /src/auth/signup.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/clerk-react"; 2 | import Logo from "../components/logo"; 3 | import { dark } from "@clerk/themes"; 4 | import { useState, useEffect } from "react"; 5 | import { useLocation } from "react-router-dom"; 6 | 7 | function Signup() { 8 | const [isLoading, setIsLoading] = useState(true); 9 | const location = useLocation(); 10 | const searchParams = new URLSearchParams(location.search); 11 | const selectedPlan = searchParams.get('plan') || 'free'; 12 | 13 | useEffect(() => { 14 | const timer = setTimeout(() => { 15 | setIsLoading(false); 16 | }, 500); 17 | 18 | return () => clearTimeout(timer); 19 | }, []); 20 | 21 | if (isLoading) { 22 | return ( 23 |
24 | 25 |
26 | ); 27 | } 28 | 29 | return ( 30 |
31 |
32 | 41 |
42 |
43 | ); 44 | } 45 | 46 | export default Signup; -------------------------------------------------------------------------------- /convex/boardSharing.ts: -------------------------------------------------------------------------------- 1 | import { mutation, query } from "./_generated/server"; 2 | import { v } from "convex/values"; 3 | 4 | export const shareBoard = mutation({ 5 | args: { boardId: v.id("boards") }, 6 | handler: async (ctx, args) => { 7 | const board = await ctx.db.get(args.boardId); 8 | if (!board) throw new Error("Board not found"); 9 | 10 | if (board.isShared && board.shareCode) { 11 | return board.shareCode; 12 | } 13 | 14 | const shareCode = Math.random().toString(36).substring(2, 8).toUpperCase(); 15 | await ctx.db.patch(args.boardId, { isShared: true, shareCode }); 16 | return shareCode; 17 | }, 18 | }); 19 | 20 | export const getSharedBoardId = query({ 21 | args: { shareCode: v.string() }, 22 | handler: async (ctx, args) => { 23 | const board = await ctx.db 24 | .query("boards") 25 | .withIndex("by_shareCode", (q) => q.eq("shareCode", args.shareCode)) 26 | .first(); 27 | return board && board.isShared ? board._id : null; 28 | }, 29 | }); 30 | 31 | export const toggleBoardSharing = mutation({ 32 | args: { boardId: v.id("boards") }, 33 | handler: async (ctx, args) => { 34 | const board = await ctx.db.get(args.boardId); 35 | if (!board) throw new Error("Board not found"); 36 | 37 | const isShared = !board.isShared; 38 | await ctx.db.patch(args.boardId, { isShared }); 39 | 40 | if (!isShared) { 41 | await ctx.db.patch(args.boardId, { shareCode: undefined }); 42 | } else if (!board.shareCode) { 43 | const shareCode = Math.random().toString(36).substring(2, 8).toUpperCase(); 44 | await ctx.db.patch(args.boardId, { shareCode }); 45 | } 46 | 47 | return isShared; 48 | }, 49 | }); -------------------------------------------------------------------------------- /src/authenticated/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | type Props = { 4 | onClick: () => void; 5 | children: React.ReactNode; 6 | isActive?: boolean; 7 | disabled?: boolean; 8 | tooltip: string; 9 | }; 10 | 11 | export default function IconButton({ 12 | onClick, 13 | children, 14 | isActive, 15 | disabled, 16 | tooltip, 17 | }: Props) { 18 | const [showTooltip, setShowTooltip] = useState(false); 19 | 20 | return ( 21 |
22 | 49 | {showTooltip && ( 50 |
51 | {tooltip} 52 |
53 | )} 54 |
55 | ); 56 | } -------------------------------------------------------------------------------- /src/components/UserCursor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | export const COLORS = ["#DC2626", "#D97706", "#059669", "#7C3AED", "#DB2777", "#2563EB", "#7C3AED", "#C026D3", "#059669", "#CA8A04"]; 3 | 4 | interface UserCursorProps { 5 | position: { x: number; y: number }; 6 | color: string; 7 | name: string; 8 | } 9 | 10 | 11 | const UserCursor: React.FC = ({ position, color, name }) => { 12 | const [currentPosition, setCurrentPosition] = useState(position); 13 | 14 | useEffect(() => { 15 | const animationDuration = 500; 16 | const startTime = Date.now(); 17 | const startPosition = { ...currentPosition }; 18 | 19 | const animate = () => { 20 | const now = Date.now(); 21 | const progress = Math.min((now - startTime) / animationDuration, 1); 22 | 23 | setCurrentPosition({ 24 | x: startPosition.x + (position.x - startPosition.x) * progress, 25 | y: startPosition.y + (position.y - startPosition.y) * progress, 26 | }); 27 | 28 | if (progress < 1) { 29 | requestAnimationFrame(animate); 30 | } 31 | }; 32 | 33 | requestAnimationFrame(animate); 34 | }, [position]); 35 | 36 | return ( 37 |
45 | 46 | 47 | 48 | 56 | {name} 57 | 58 |
59 | ); 60 | }; 61 | 62 | export default UserCursor; -------------------------------------------------------------------------------- /convex/emails.ts: -------------------------------------------------------------------------------- 1 | "use node"; 2 | 3 | import { internalAction } from "./_generated/server"; 4 | import { v } from "convex/values"; 5 | import { Resend as ResendAPI } from "resend"; 6 | import { WelcomeEmail } from "./welcomeEmail"; 7 | import { internal } from "./_generated/api"; 8 | 9 | const resend = new ResendAPI(process.env.RESEND_API_KEY); 10 | 11 | export const sendWelcomeEmail = internalAction({ 12 | args: { 13 | userId: v.id("users"), 14 | email: v.string(), 15 | name: v.string() 16 | }, 17 | handler: async (ctx, args) => { 18 | const { userId, email, name } = args; 19 | 20 | // Check if welcome email has already been sent 21 | const IsSent = await ctx.runQuery(internal.users.existingLogforEmail, { userId }) 22 | 23 | if (IsSent) { 24 | return false; 25 | } 26 | 27 | try { 28 | await resend.emails.send({ 29 | from: 'Sticky.today ', 30 | to: email, 31 | subject: 'Welcome to Sticky - Let\'s make your ideas stick!', 32 | react: WelcomeEmail({ name: name }), 33 | text: ` 34 | Welcome to Sticky, ${name}! 35 | 36 | Ideas that stick, literally! 😉 37 | 38 | You've just found the duct tape for your brain! 39 | 40 | Get started: https://www.sticky.today/boards 41 | 42 | Need help? Visit your Board and use the ? button to ask. 43 | 44 | Best, 45 | The Sticky team 46 | `.trim() 47 | }); 48 | 49 | 50 | // Log the sent email 51 | await ctx.runMutation(internal.users.LogEmailSend, { 52 | userId: userId, 53 | type: "welcome" 54 | }) 55 | 56 | } catch (error) { 57 | console.error(`Failed to send welcome email to user ${userId}:`, error); 58 | } 59 | }, 60 | }); -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* prettier-ignore-start */ 2 | 3 | /* eslint-disable */ 4 | /** 5 | * Generated data model types. 6 | * 7 | * THIS CODE IS AUTOMATICALLY GENERATED. 8 | * 9 | * To regenerate, run `npx convex dev`. 10 | * @module 11 | */ 12 | 13 | import type { 14 | DataModelFromSchemaDefinition, 15 | DocumentByName, 16 | TableNamesInDataModel, 17 | SystemTableNames, 18 | } from "convex/server"; 19 | import type { GenericId } from "convex/values"; 20 | import schema from "../schema.js"; 21 | 22 | /** 23 | * The names of all of your Convex tables. 24 | */ 25 | export type TableNames = TableNamesInDataModel; 26 | 27 | /** 28 | * The type of a document stored in Convex. 29 | * 30 | * @typeParam TableName - A string literal type of the table name (like "users"). 31 | */ 32 | export type Doc = DocumentByName< 33 | DataModel, 34 | TableName 35 | >; 36 | 37 | /** 38 | * An identifier for a document in Convex. 39 | * 40 | * Convex documents are uniquely identified by their `Id`, which is accessible 41 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 42 | * 43 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 44 | * 45 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 46 | * strings when type checking. 47 | * 48 | * @typeParam TableName - A string literal type of the table name (like "users"). 49 | */ 50 | export type Id = 51 | GenericId; 52 | 53 | /** 54 | * A type describing your Convex data model. 55 | * 56 | * This type includes information about what tables you have, the type of 57 | * documents stored in those tables, and the indexes defined on them. 58 | * 59 | * This type is used to parameterize methods like `queryGeneric` and 60 | * `mutationGeneric` to make them type-safe. 61 | */ 62 | export type DataModel = DataModelFromSchemaDefinition; 63 | 64 | /* prettier-ignore-end */ 65 | -------------------------------------------------------------------------------- /src/components/lander/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Logo from '../logo'; 3 | import { ThemeSwitcher } from '../theme-switcher'; 4 | import { Link } from 'react-router-dom'; 5 | import { HalloweenSwitcher } from '../halloween-switcher'; 6 | import { useHalloween } from '../../providers/halloween-provider'; 7 | 8 | const Footer: React.FC = () => { 9 | const { isHalloweenMode } = useHalloween(); 10 | 11 | return ( 12 |
17 |
18 |
19 |
20 | 21 | Sticky 24 |
25 |
26 | Terms 29 | Privacy 32 | 33 | 34 |
35 |
36 |
39 | © {new Date().getUTCFullYear()} Sticky. {isHalloweenMode ? 'All souls reserved.' : 'All rights reserved.'} 40 |
41 |
42 |
43 | ); 44 | }; 45 | 46 | export default Footer; -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./src/**/*.{html,js,jsx,ts,tsx,mdx}"], 4 | theme: { 5 | extend: { 6 | colors: { 7 | main: '#FFDC58', 8 | mainAccent: '#ffc800', 9 | overlay: 'rgba(0,0,0,0.8)', 10 | 11 | // light mode 12 | bg: '#FEF2E8', 13 | text: '#1f1f1f', 14 | border: '#000', 15 | 16 | // dark mode 17 | darkBg: '#1f1f1f', 18 | darkText: '#eeefe9', 19 | darkBorder: '#000', 20 | secondaryBlack: '#1b1b1b', 21 | 22 | text: { 23 | DEFAULT: '#1f1f1f', 24 | dark: '#eeefe9', 25 | }, 26 | halloween: { 27 | orange: '#FF6B1A', 28 | purple: '#6B1AFF', 29 | green: '#4CAF50', 30 | black: '#1a1a1a', 31 | ghost: 'rgba(255, 255, 255, 0.9)', 32 | }, 33 | }, 34 | keyframes: { 35 | float: { 36 | '0%, 100%': { transform: 'translateY(0)' }, 37 | '50%': { transform: 'translateY(-20px)' }, 38 | }, 39 | spookyShake: { 40 | '0%, 100%': { transform: 'rotate(0deg)' }, 41 | '25%': { transform: 'rotate(-5deg)' }, 42 | '75%': { transform: 'rotate(5deg)' }, 43 | }, 44 | }, 45 | animation: { 46 | 'float': 'float 6s ease-in-out infinite', 47 | 'spooky-shake': 'spookyShake 2s ease-in-out infinite', 48 | }, 49 | borderRadius: { 50 | base: '20px' 51 | }, 52 | boxShadow: { 53 | light: '3px 3px 0px 0px #000', 54 | dark: '3px 3px 0px 0px #000', 55 | white: '3px 3px 0px 0px #fff', 56 | }, 57 | translate: { 58 | boxShadowX: '3px', 59 | boxShadowY: '3px', 60 | reverseBoxShadowX: '-3px', 61 | reverseBoxShadowY: '-3px', 62 | }, 63 | fontWeight: { 64 | base: '500', 65 | heading: '800', 66 | }, 67 | minHeight: { 68 | screen: '100vh', 69 | }, 70 | }, 71 | }, 72 | darkMode: 'class', 73 | plugins: [], 74 | } 75 | 76 | -------------------------------------------------------------------------------- /src/hooks/usePresence.ts: -------------------------------------------------------------------------------- 1 | import { useQuery, useMutation } from "convex/react"; 2 | import { api } from "../../convex/_generated/api"; 3 | import { useEffect, useCallback, useRef, useState } from "react"; 4 | import { Id } from "../../convex/_generated/dataModel"; 5 | import debounce from 'lodash.debounce'; 6 | 7 | const PRESENCE_UPDATE_INTERVAL = 100; 8 | const HEARTBEAT_INTERVAL = 30000; 9 | 10 | export function usePresence(boardId: Id<"boards">, isShared: boolean) { 11 | const updatePresence = useMutation(api.presence.updatePresence); 12 | const removePresence = useMutation(api.presence.removePresence); 13 | const activeUsers = useQuery(api.presence.getActiveUsers, { boardId }); 14 | const cursorPositionRef = useRef({ x: 0, y: 0 }); 15 | const [localCursorPosition, setLocalCursorPosition] = useState({ x: 0, y: 0 }); 16 | 17 | const debouncedUpdatePresence = useCallback( 18 | debounce((position: { x: number; y: number }) => { 19 | if (isShared) { 20 | updatePresence({ 21 | boardId, 22 | cursorPosition: position, 23 | isHeartbeat: false 24 | }); 25 | } 26 | }, PRESENCE_UPDATE_INTERVAL, { maxWait: PRESENCE_UPDATE_INTERVAL * 2 }), 27 | [boardId, updatePresence, isShared] 28 | ); 29 | 30 | const updateCursorPosition = useCallback((position: { x: number; y: number }) => { 31 | cursorPositionRef.current = position; 32 | setLocalCursorPosition(position); 33 | if (isShared) { 34 | debouncedUpdatePresence(position); 35 | } 36 | }, [debouncedUpdatePresence, isShared]); 37 | 38 | useEffect(() => { 39 | if (!isShared) return; 40 | 41 | const heartbeatInterval = setInterval(() => { 42 | updatePresence({ 43 | boardId, 44 | cursorPosition: cursorPositionRef.current, 45 | isHeartbeat: true 46 | }); 47 | }, HEARTBEAT_INTERVAL); 48 | 49 | return () => { 50 | clearInterval(heartbeatInterval); 51 | removePresence({ boardId }); 52 | }; 53 | }, [boardId, updatePresence, removePresence, isShared]); 54 | 55 | return { 56 | activeUsers: isShared ? activeUsers : [], 57 | updateCursorPosition, 58 | localCursorPosition 59 | }; 60 | } -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from "convex/server"; 2 | import { v } from "convex/values"; 3 | 4 | export default defineSchema({ 5 | users: defineTable({ 6 | name: v.string(), 7 | email: v.optional(v.string()), 8 | profileImageUrl: v.optional(v.string()), 9 | tokenIdentifier: v.string(), 10 | plan: v.optional(v.string()), 11 | onBoarding: v.optional(v.boolean()) 12 | }).index("by_token", ["tokenIdentifier"]), 13 | 14 | boards: defineTable({ 15 | name: v.string(), 16 | ownerId: v.id("users"), 17 | isShared: v.boolean(), 18 | shareCode: v.optional(v.string()), 19 | notesCount: v.optional(v.number()), 20 | inTrash: v.boolean(), 21 | _creationTime: v.number(), 22 | lastModified: v.number(), 23 | }) 24 | .index("by_owner", ["ownerId"]) 25 | .index("by_owner_and_modified", ["ownerId", "lastModified"]) 26 | .index("by_owner_and_name", ["ownerId", "name"]) 27 | .index("by_owner_and_notes", ["ownerId", "notesCount"]) 28 | .index("by_shareCode", ["shareCode"]) 29 | .searchIndex("search_name", { 30 | searchField: "name", 31 | filterFields: ["ownerId", "inTrash"] 32 | }), 33 | 34 | notes: defineTable({ 35 | boardId: v.id("boards"), 36 | content: v.string(), 37 | color: v.string(), 38 | position: v.object({ 39 | x: v.number(), 40 | y: v.number(), 41 | }), 42 | size: v.object({ 43 | width: v.number(), 44 | height: v.number(), 45 | }), 46 | zIndex: v.optional(v.number()), 47 | }).index("by_board", ["boardId"]), 48 | 49 | presence: defineTable({ 50 | userId: v.id("users"), 51 | boardId: v.id("boards"), 52 | lastUpdated: v.number(), 53 | cursorPosition: v.object({ 54 | x: v.number(), 55 | y: v.number() 56 | }), 57 | }).index("by_board", ["boardId"]) 58 | .index("by_user_and_board", ["userId", "boardId"]) 59 | .index("by_board_and_lastUpdated", ["boardId", "lastUpdated"]), 60 | 61 | supportRequest: defineTable({ 62 | userId: v.id("users"), 63 | input: v.string() 64 | }), 65 | 66 | emailLogs: defineTable({ 67 | userId: v.id("users"), 68 | type: v.string(), 69 | sentAt: v.number(), 70 | }).index("by_user_and_type", ["userId", "type"]), 71 | }); -------------------------------------------------------------------------------- /src/components/halloween-decorations.tsx: -------------------------------------------------------------------------------- 1 | import { useHalloween } from '../providers/halloween-provider'; 2 | 3 | const HalloweenDecorations = () => { 4 | const { isHalloweenMode } = useHalloween(); 5 | 6 | if (!isHalloweenMode) return null; 7 | 8 | const decorations = [ 9 | { emoji: '👻', position: 'top-20 left-10', delay: 'delay-0' }, 10 | { emoji: '🎃', position: 'top-40 right-20', delay: 'delay-1000' }, 11 | { emoji: '🦇', position: 'bottom-20 left-30', delay: 'delay-2000' }, 12 | { emoji: '💀', position: 'bottom-40 right-30', delay: 'delay-3000' }, 13 | { emoji: '🕸️', position: 'top-60 left-1/4', delay: 'delay-4000' }, 14 | { emoji: '🕯️', position: 'bottom-60 right-1/4', delay: 'delay-5000' }, 15 | ]; 16 | 17 | if (location.pathname.includes('/board')) { 18 | return ( 19 | <> 20 |
21 |
22 |
23 |
24 | 25 | ); 26 | } 27 | 28 | return ( 29 | <> 30 |
31 | {decorations.map((decoration, index) => ( 32 |
33 |
34 | {decoration.emoji} 35 |
36 |
37 | ))} 38 | 39 |
40 |
41 |
42 | 43 | ); 44 | }; 45 | 46 | export default HalloweenDecorations; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sticky", 3 | "private": false, 4 | "version": "0.2.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/hamzasaleem2/sticky" 15 | }, 16 | "keywords": [ 17 | "sticky-notes", 18 | "collaboration", 19 | "organization", 20 | "react", 21 | "typescript", 22 | "convex.dev" 23 | ], 24 | "author": "Hamza Saleem", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/hamzasaleem2/sticky/issues" 28 | }, 29 | "homepage": "https://github.com/hamzasaleem2/sticky#readme", 30 | "dependencies": { 31 | "@clerk/clerk-react": "^5.11.0", 32 | "@clerk/themes": "^2.1.35", 33 | "@convex-dev/aggregate": "^0.1.14", 34 | "@radix-ui/react-toast": "^1.2.2", 35 | "@radix-ui/react-tooltip": "^1.1.3", 36 | "@react-email/components": "^0.0.41", 37 | "@types/lodash.debounce": "^4.0.9", 38 | "@types/react-lazy-load-image-component": "^1.6.4", 39 | "class-variance-authority": "^0.7.0", 40 | "clsx": "^2.1.1", 41 | "convex": "^1.20.0", 42 | "convex-helpers": "^0.1.60", 43 | "lodash.debounce": "^4.0.8", 44 | "lucide-react": "^0.447.0", 45 | "next-themes": "^0.3.0", 46 | "posthog-js": "^1.180.0", 47 | "react": "^18.3.1", 48 | "react-dom": "^18.3.1", 49 | "react-lazy-load-image-component": "^1.6.2", 50 | "react-router-dom": "^6.26.2", 51 | "resend": "^4.0.0", 52 | "tailwind-merge": "^2.5.2", 53 | "use-image": "^1.1.1" 54 | }, 55 | "devDependencies": { 56 | "@eslint/js": "^9.9.0", 57 | "@types/node": "^22.7.4", 58 | "@types/react": "^18.3.3", 59 | "@types/react-dom": "^18.3.0", 60 | "@types/uuid": "^10.0.0", 61 | "@vitejs/plugin-react": "^4.3.4", 62 | "autoprefixer": "^10.4.20", 63 | "eslint": "^9.9.0", 64 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 65 | "eslint-plugin-react-refresh": "^0.4.9", 66 | "globals": "^15.9.0", 67 | "postcss": "^8.4.47", 68 | "tailwindcss": "^3.4.13", 69 | "typescript": "^5.5.3", 70 | "typescript-eslint": "^8.0.1", 71 | "vite": "^6.3.5" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/components/ui/modal.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { X } from 'lucide-react' 4 | import ReactDom from 'react-dom' 5 | 6 | import React, { useEffect, useState } from 'react' 7 | 8 | type Props = { 9 | active: boolean 10 | setActive: React.Dispatch> 11 | children: React.ReactNode 12 | } 13 | 14 | export default function Modal({ active, setActive, children }: Props) { 15 | const [isVisible, setIsVisible] = useState(false) 16 | 17 | const closeModal = () => { 18 | setIsVisible(false) 19 | setTimeout(() => { 20 | setActive(false) 21 | }, 300) 22 | } 23 | 24 | useEffect(() => { 25 | if (active) { 26 | setIsVisible(true) 27 | } 28 | }, [active]) 29 | 30 | if (!active) return null 31 | 32 | return ReactDom.createPortal( 33 |
40 |
e.stopPropagation()} 42 | className="relative flex w-[300px] group-data-[visible=true]:opacity-100 group-data-[visible=true]:visible group-data-[visible=false]:opacity-0 group-data-[visible=false]:invisible flex-col items-center justify-center rounded-base border-2 border-border dark:border-darkBorder bg-white dark:bg-darkBg p-10 text-text dark:text-darkText pt-12 font-base shadow-light dark:shadow-dark transition-all duration-300" 43 | > 44 | 47 | {children} 48 | 54 |
55 |
, 56 | document.getElementById('modal') as HTMLElement, 57 | ) 58 | } -------------------------------------------------------------------------------- /convex/notes.ts: -------------------------------------------------------------------------------- 1 | import { mutation, query } from "./_generated/server"; 2 | import { v } from "convex/values"; 3 | import { internal } from "./_generated/api"; 4 | 5 | export const createNote = mutation({ 6 | args: { 7 | boardId: v.id("boards"), 8 | content: v.string(), 9 | color: v.string(), 10 | position: v.object({ 11 | x: v.number(), 12 | y: v.number(), 13 | }), 14 | size: v.object({ 15 | width: v.number(), 16 | height: v.number(), 17 | }), 18 | zIndex: v.number(), 19 | }, 20 | handler: async (ctx, args) => { 21 | const noteId = await ctx.db.insert("notes", args); 22 | await ctx.runMutation(internal.boards.updateNotesCount, { boardId: args.boardId, increment: 1 }); 23 | await ctx.db.patch(args.boardId, { lastModified: Date.now() }); 24 | return noteId; 25 | }, 26 | }); 27 | 28 | export const updateNote = mutation({ 29 | args: { 30 | noteId: v.id("notes"), 31 | content: v.optional(v.string()), 32 | color: v.optional(v.string()), 33 | position: v.optional(v.object({ 34 | x: v.number(), 35 | y: v.number(), 36 | })), 37 | size: v.optional(v.object({ 38 | width: v.number(), 39 | height: v.number(), 40 | })), 41 | zIndex: v.optional(v.number()), 42 | }, 43 | handler: async (ctx, args) => { 44 | const { noteId, ...updates } = args; 45 | const note = await ctx.db.get(noteId); 46 | if (!note) throw new Error("Note not found"); 47 | 48 | await ctx.db.patch(noteId, updates); 49 | 50 | await ctx.db.patch(note.boardId, { lastModified: Date.now() }); 51 | }, 52 | }); 53 | 54 | export const deleteNote = mutation({ 55 | args: { noteId: v.id("notes") }, 56 | handler: async (ctx, args) => { 57 | const note = await ctx.db.get(args.noteId); 58 | if (!note) throw new Error("Note not found"); 59 | 60 | await ctx.db.delete(args.noteId); 61 | await ctx.runMutation(internal.boards.updateNotesCount, { boardId: note.boardId, increment: -1 }); 62 | await ctx.db.patch(note.boardId, { lastModified: Date.now() }); 63 | }, 64 | }); 65 | 66 | export const getNotes = query({ 67 | args: { boardId: v.id("boards") }, 68 | handler: async (ctx, args) => { 69 | return await ctx.db 70 | .query("notes") 71 | .withIndex("by_board", (q) => q.eq("boardId", args.boardId)) 72 | .collect(); 73 | }, 74 | }); -------------------------------------------------------------------------------- /src/components/privacy.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const PrivacyPolicy: React.FC = () => { 4 | return ( 5 |
6 |
7 |

Privacy Policy

8 |
9 |
10 |

1. Sticky Notes, Not Sticky Situations

11 |

At Sticky, we're all about keeping your ideas organized, not your personal data. We promise to treat your information with the same care you'd give to your most precious sticky note collection.

12 |
13 |
14 |

2. Data We Collect (It's Not Much, We Promise)

15 |

We only collect what we need to make Sticky work for you. This includes your email, your brilliant ideas (in sticky note form), and occasionally, the number of times you've procrastinated by rearranging your virtual sticky notes.

16 |
17 |
18 |

3. How We Use Your Data

19 |

We use your data to make Sticky better, not to sell you things you don't need. Your sticky notes are for your eyes only (unless you choose to share them, of course).

20 |
21 |
22 |

4. Security: Fort Knox, but for Sticky Notes

23 |

We protect your data like it's the last piece of cake at an office party. Our security measures are top-notch, because we know your ideas are priceless.

24 |
25 |
26 |

5. Your Rights (Yes, You Have Them!)

27 |

You have the right to access, correct, or delete your data at any time. Just like you can peel off a sticky note, you can remove your data from our systems.

28 |
29 |
30 |

6. Changes to This Policy

31 |

We may update this policy occasionally, but we promise not to do it as often as you change the color of your sticky notes. We'll notify you of any significant changes.

32 |
33 |
34 |
35 |
36 | ); 37 | }; 38 | 39 | export default PrivacyPolicy; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer components { 6 | .halloween-bg { 7 | background: linear-gradient(to bottom, #1a1a1a, #2d1b3d); 8 | background-size: 400% 400%; 9 | animation: gradient 15s ease infinite; 10 | } 11 | 12 | .spooky-text { 13 | text-shadow: 0 0 10px #FF6B1A, 0 0 20px #FF6B1A, 0 0 30px #FF6B1A; 14 | } 15 | 16 | .ghost-container { 17 | position: fixed; 18 | pointer-events: none; 19 | z-index: 50; 20 | } 21 | 22 | .floating-ghost { 23 | animation: float 6s ease-in-out infinite; 24 | opacity: 0.6; 25 | } 26 | 27 | .halloween-glow { 28 | animation: glow 2s ease-in-out infinite alternate; 29 | } 30 | 31 | .spooky-hover { 32 | transition: all 0.3s ease; 33 | } 34 | 35 | .spooky-hover:hover { 36 | transform: scale(1.05) rotate(2deg); 37 | filter: brightness(1.2); 38 | } 39 | } 40 | 41 | @keyframes glow { 42 | from { 43 | text-shadow: 0 0 5px #FF6B1A, 0 0 10px #FF6B1A, 0 0 15px #FF6B1A; 44 | } 45 | to { 46 | text-shadow: 0 0 10px #FF6B1A, 0 0 20px #FF6B1A, 0 0 30px #FF6B1A; 47 | } 48 | } 49 | 50 | @keyframes gradient { 51 | 0% { 52 | background-position: 0% 50%; 53 | } 54 | 55 | 50% { 56 | background-position: 100% 50%; 57 | } 58 | 59 | 100% { 60 | background-position: 0% 50%; 61 | } 62 | } 63 | 64 | @keyframes glow { 65 | from { 66 | text-shadow: 0 0 5px #FF6B1A, 0 0 10px #FF6B1A, 0 0 15px #FF6B1A; 67 | } 68 | to { 69 | text-shadow: 0 0 10px #FF6B1A, 0 0 20px #FF6B1A, 0 0 30px #FF6B1A; 70 | } 71 | } 72 | 73 | @keyframes spookyShake { 74 | 0%, 100% { transform: translate(0, 0) rotate(0deg); } 75 | 25% { transform: translate(2px, 2px) rotate(2deg); } 76 | 50% { transform: translate(0, -2px) rotate(-2deg); } 77 | 75% { transform: translate(-2px, 2px) rotate(2deg); } 78 | } 79 | 80 | @keyframes fog { 81 | 0% { transform: translateX(-100%); } 82 | 100% { transform: translateX(100%); } 83 | } 84 | 85 | @keyframes flicker { 86 | 0%, 100% { opacity: 1; } 87 | 50% { opacity: 0.7; } 88 | } 89 | 90 | .halloween-card-hover { 91 | transition: all 0.3s ease; 92 | } 93 | 94 | .halloween-card-hover:hover { 95 | transform: translateY(-5px) rotate(2deg); 96 | box-shadow: 0 10px 20px rgba(255, 107, 26, 0.2); 97 | } 98 | 99 | .spooky-text-shadow { 100 | text-shadow: 2px 2px 4px rgba(255, 107, 26, 0.5); 101 | } -------------------------------------------------------------------------------- /src/components/HelpModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | import Modal from './ui/modal'; 3 | import { Button } from './ui/button'; 4 | import Input from './ui/input'; 5 | import { useMutation } from 'convex/react'; 6 | import { api } from '../../convex/_generated/api'; 7 | import { useToast } from '../hooks/use-toast'; 8 | 9 | interface HelpModalProps { 10 | isOpen: boolean; 11 | onClose: () => void; 12 | } 13 | 14 | const HelpModal: React.FC = ({ isOpen, onClose }) => { 15 | const [input, setInput] = useState(''); 16 | const [error, setError] = useState(''); 17 | const submitHelpRequest = useMutation(api.support.supportRequest); 18 | const { toast } = useToast(); 19 | 20 | const handleInputChange = useCallback((e: React.ChangeEvent) => { 21 | setInput(e.target.value); 22 | setError(''); 23 | }, []); 24 | 25 | const handleSubmit = async () => { 26 | if (!input.trim()) { 27 | setError('Please enter a question before submitting.'); 28 | return; 29 | } 30 | 31 | try { 32 | await submitHelpRequest({ input }); 33 | toast({ 34 | title: "Help Request Submitted", 35 | description: "We've received your question and will get back to you soon!", 36 | }); 37 | setInput(''); 38 | onClose(); 39 | } catch (error) { 40 | toast({ 41 | title: "Error", 42 | description: "Failed to submit help request. Please try again.", 43 | variant: "destructive", 44 | }); 45 | } 46 | }; 47 | 48 | return ( 49 | 50 |

Need Help?

51 |

Ask your question below, and we'll get back to you as soon as possible!

52 | 58 | {error &&

{error}

} 59 |
60 | 66 |
67 |
68 | ); 69 | }; 70 | 71 | export default HelpModal; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Ideas that Stick, literally 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 54 | 55 | 56 |
57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /convex/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex functions directory! 2 | 3 | Write your Convex functions here. 4 | See https://docs.convex.dev/functions for more. 5 | 6 | A query function that takes two arguments looks like: 7 | 8 | ```ts 9 | // functions.js 10 | import { query } from "./_generated/server"; 11 | import { v } from "convex/values"; 12 | 13 | export const myQueryFunction = query({ 14 | // Validators for arguments. 15 | args: { 16 | first: v.number(), 17 | second: v.string(), 18 | }, 19 | 20 | // Function implementation. 21 | handler: async (ctx, args) => { 22 | // Read the database as many times as you need here. 23 | // See https://docs.convex.dev/database/reading-data. 24 | const documents = await ctx.db.query("tablename").collect(); 25 | 26 | // Arguments passed from the client are properties of the args object. 27 | console.log(args.first, args.second); 28 | 29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data, 30 | // remove non-public properties, or create new objects. 31 | return documents; 32 | }, 33 | }); 34 | ``` 35 | 36 | Using this query function in a React component looks like: 37 | 38 | ```ts 39 | const data = useQuery(api.functions.myQueryFunction, { 40 | first: 10, 41 | second: "hello", 42 | }); 43 | ``` 44 | 45 | A mutation function looks like: 46 | 47 | ```ts 48 | // functions.js 49 | import { mutation } from "./_generated/server"; 50 | import { v } from "convex/values"; 51 | 52 | export const myMutationFunction = mutation({ 53 | // Validators for arguments. 54 | args: { 55 | first: v.string(), 56 | second: v.string(), 57 | }, 58 | 59 | // Function implementation. 60 | handler: async (ctx, args) => { 61 | // Insert or modify documents in the database here. 62 | // Mutations can also read from the database like queries. 63 | // See https://docs.convex.dev/database/writing-data. 64 | const message = { body: args.first, author: args.second }; 65 | const id = await ctx.db.insert("messages", message); 66 | 67 | // Optionally, return a value from your mutation. 68 | return await ctx.db.get(id); 69 | }, 70 | }); 71 | ``` 72 | 73 | Using this mutation function in a React component looks like: 74 | 75 | ```ts 76 | const mutation = useMutation(api.functions.myMutationFunction); 77 | function handleButtonPress() { 78 | // fire and forget, the most common way to use mutations 79 | mutation({ first: "Hello!", second: "me" }); 80 | // OR 81 | // use the result once the mutation has completed 82 | mutation({ first: "Hello!", second: "me" }).then((result) => 83 | console.log(result), 84 | ); 85 | } 86 | ``` 87 | 88 | Use the Convex CLI to push your functions to a deployment. See everything 89 | the Convex CLI can do by running `npx convex -h` in your project root 90 | directory. To learn more, launch the docs with `npx convex docs`. 91 | -------------------------------------------------------------------------------- /src/components/ui/dropdown.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ChevronDown } from 'lucide-react' 4 | import { useState, useRef, useEffect } from 'react' 5 | 6 | type DropdownItem = { 7 | name: string; 8 | link: string; 9 | } 10 | 11 | type DropdownProps = { 12 | items: DropdownItem[]; 13 | text: string; 14 | onSelect: (option: string) => void; 15 | } 16 | 17 | export default function Dropdown({ items, text, onSelect }: DropdownProps) { 18 | const [isActiveDropdown, setIsActiveDropdown] = useState(false) 19 | const dropdownRef = useRef(null) 20 | 21 | useEffect(() => { 22 | const handleClickOutside = (event: MouseEvent) => { 23 | if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { 24 | setIsActiveDropdown(false) 25 | } 26 | } 27 | 28 | document.addEventListener('mousedown', handleClickOutside) 29 | return () => { 30 | document.removeEventListener('mousedown', handleClickOutside) 31 | } 32 | }, []) 33 | 34 | const handleItemClick = (item: DropdownItem) => { 35 | onSelect(item.name); 36 | setIsActiveDropdown(false); 37 | } 38 | 39 | return ( 40 |
45 | 60 | {isActiveDropdown && ( 61 |
65 | {items.map((item, index) => ( 66 | 73 | ))} 74 |
75 | )} 76 |
77 | ) 78 | } -------------------------------------------------------------------------------- /src/components/terms.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const TermsOfConditions: React.FC = () => { 4 | return ( 5 |
6 |
7 |

Terms of Conditions

8 |
9 |
10 |

1. Acceptance of Terms

11 |

By using Sticky, you agree to these terms. If you don't agree, well, we'll be sad, but you shouldn't use Sticky. It's like agreeing to play a board game – you've got to follow the rules to have fun!

12 |
13 |
14 |

2. Use of Service

15 |

Use Sticky for good, not evil. Don't do anything illegal, harmful, or that would make your grandmother disappointed in you. Sticky is for organizing ideas, not planning world domination (unless it's in a fun, non-threatening way).

16 |
17 |
18 |

3. User Accounts

19 |

Keep your account info safe and sound. You're responsible for everything that happens under your account, so don't share it – not even with your cat, no matter how trustworthy they seem.

20 |
21 |
22 |

4. Content Responsibility

23 |

You're responsible for your sticky notes. We're not liable for any embarrassing ideas you jot down at 3 AM. Remember, with great sticky power comes great sticky responsibility.

24 |
25 |
26 |

5. Intellectual Property

27 |

We own Sticky, you own your stickies. It's a beautiful relationship, let's keep it that way. Don't try to claim you invented Sticky – we all know it was the ancient Egyptians who first used sticky notes (okay, maybe not, but you get the idea).

28 |
29 |
30 |

6. Termination

31 |

We reserve the right to terminate accounts for misconduct. Don't make us use this power – we prefer to use our energy for creating awesome features instead.

32 |
33 |
34 |

7. Changes to Terms

35 |

We may update these terms occasionally. We'll let you know when we do, but it's up to you to stay informed. Think of it like checking your sticky notes for updates!

36 |
37 |
38 |
39 |
40 | ); 41 | }; 42 | 43 | export default TermsOfConditions; -------------------------------------------------------------------------------- /src/components/FeedbackModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | import Modal from './ui/modal'; 3 | import { Button } from './ui/button'; 4 | import { useMutation } from 'convex/react'; 5 | import { api } from '../../convex/_generated/api'; 6 | import { useToast } from '../hooks/use-toast'; 7 | 8 | interface FeedbackModalProps { 9 | isOpen: boolean; 10 | onClose: () => void; 11 | } 12 | 13 | const FeedbackModal: React.FC = ({ isOpen, onClose }) => { 14 | const [input, setInput] = useState(''); 15 | const [error, setError] = useState(''); 16 | const submitHelpRequest = useMutation(api.support.supportRequest); 17 | const { toast } = useToast(); 18 | 19 | const handleInputChange = useCallback((e: React.ChangeEvent) => { 20 | setInput(e.target.value); 21 | setError(''); 22 | }, []); 23 | 24 | const handleSubmit = async () => { 25 | if (!input.trim()) { 26 | setError('Please enter your feedback before submitting.'); 27 | return; 28 | } 29 | 30 | try { 31 | await submitHelpRequest({ input }); 32 | toast({ 33 | title: "Feedback Submitted", 34 | description: "Thank you for your feedback! We appreciate your input.", 35 | }); 36 | setInput(''); 37 | onClose(); 38 | } catch (error) { 39 | toast({ 40 | title: "Error", 41 | description: "Failed to submit feedback. Please try again.", 42 | variant: "destructive", 43 | }); 44 | } 45 | }; 46 | 47 | return ( 48 | 49 |

We Value Your Feedback!

50 |

Your thoughts help us improve. Share your experience or suggestions below:

51 |