feat: Add Magic UI components including DotPattern, RainbowButton, ShineBorder, WordRotate, and BlurIn, along with corresponding Tailwind configuration updates.

This commit is contained in:
2026-02-03 16:23:28 +00:00
parent 8d1923203d
commit 494acebeb3
14 changed files with 752 additions and 237 deletions

View File

@@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
const { analysis } = bodySchema.parse(body) const { analysis } = bodySchema.parse(body)
console.log(`🔍 Finding opportunities for: ${analysis.productName}`) console.log(`🔍 Finding opportunities for: ${analysis.productName}`)
// Sort queries by priority // Sort queries by priority
const sortedQueries = analysis.dorkQueries const sortedQueries = analysis.dorkQueries
.sort((a, b) => { .sort((a, b) => {
@@ -49,14 +49,14 @@ export async function POST(request: NextRequest) {
.slice(0, 15) // Limit to top 15 queries .slice(0, 15) // Limit to top 15 queries
const allResults: SearchResult[] = [] const allResults: SearchResult[] = []
// Execute searches // Execute searches
for (const query of sortedQueries) { for (const query of sortedQueries) {
try { try {
console.log(` Searching: ${query.query.substring(0, 60)}...`) console.log(` Searching: ${query.query.substring(0, 60)}...`)
const results = await searchGoogle(query.query, 5) const results = await searchGoogle(query.query, 5)
allResults.push(...results) allResults.push(...results)
// Small delay to avoid rate limiting // Small delay to avoid rate limiting
await new Promise(r => setTimeout(r, 500)) await new Promise(r => setTimeout(r, 500))
} catch (e) { } catch (e) {
@@ -85,7 +85,7 @@ export async function POST(request: NextRequest) {
} catch (error: any) { } catch (error: any) {
console.error('❌ Search error:', error) console.error('❌ Search error:', error)
return NextResponse.json( return NextResponse.json(
{ error: error.message || 'Failed to find opportunities' }, { error: error.message || 'Failed to find opportunities' },
{ status: 500 } { status: 500 }
@@ -136,15 +136,15 @@ async function searchDirect(query: string, num: number): Promise<SearchResult[]>
const html = await response.text() const html = await response.text()
const results: SearchResult[] = [] const results: SearchResult[] = []
// Simple regex parsing // Simple regex parsing
const resultBlocks = html.match(/<div class="g"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g) || [] const resultBlocks = html.match(/<div class="g"[^>]*>([\s\S]*?)<\/div>\s*<\/div>/g) || []
for (const block of resultBlocks.slice(0, num)) { for (const block of resultBlocks.slice(0, num)) {
const titleMatch = block.match(/<h3[^>]*>(.*?)<\/h3>/) const titleMatch = block.match(/<h3[^>]*>(.*?)<\/h3>/)
const linkMatch = block.match(/<a href="([^"]+)"/) const linkMatch = block.match(/<a href="([^"]+)"/)
const snippetMatch = block.match(/<div class="VwiC3b[^"]*"[^>]*>(.*?)<\/div>/) const snippetMatch = block.match(/<div class="VwiC3b[^"]*"[^>]*>(.*?)<\/div>/)
if (titleMatch && linkMatch) { if (titleMatch && linkMatch) {
results.push({ results.push({
title: titleMatch[1].replace(/<[^>]+>/g, ''), title: titleMatch[1].replace(/<[^>]+>/g, ''),
@@ -169,7 +169,7 @@ function getSource(url: string): string {
} }
async function analyzeOpportunities( async function analyzeOpportunities(
results: SearchResult[], results: SearchResult[],
analysis: EnhancedProductAnalysis analysis: EnhancedProductAnalysis
): Promise<Opportunity[]> { ): Promise<Opportunity[]> {
const opportunities: Opportunity[] = [] const opportunities: Opportunity[] = []
@@ -181,17 +181,17 @@ async function analyzeOpportunities(
// Calculate relevance score // Calculate relevance score
const content = (result.title + ' ' + result.snippet).toLowerCase() const content = (result.title + ' ' + result.snippet).toLowerCase()
// Match keywords // Match keywords
const matchedKeywords = analysis.keywords const matchedKeywords = analysis.keywords
.filter(k => content.includes(k.term.toLowerCase())) .filter(k => content.includes(k.term.toLowerCase()))
.map(k => k.term) .map(k => k.term)
// Match problems // Match problems
const matchedProblems = analysis.problemsSolved const matchedProblems = analysis.problemsSolved
.filter(p => content.includes(p.problem.toLowerCase())) .filter(p => content.includes(p.problem.toLowerCase()))
.map(p => p.problem) .map(p => p.problem)
// Calculate score // Calculate score
const keywordScore = Math.min(matchedKeywords.length * 0.15, 0.6) const keywordScore = Math.min(matchedKeywords.length * 0.15, 0.6)
const problemScore = Math.min(matchedProblems.length * 0.2, 0.4) const problemScore = Math.min(matchedProblems.length * 0.2, 0.4)
@@ -210,7 +210,7 @@ async function analyzeOpportunities(
} }
// Find matching persona // Find matching persona
const matchedPersona = analysis.personas.find(p => const matchedPersona = analysis.personas.find(p =>
p.searchBehavior.some(b => content.includes(b.toLowerCase())) p.searchBehavior.some(b => content.includes(b.toLowerCase()))
)?.name )?.name

View File

@@ -24,16 +24,23 @@
--input: 240 3.7% 15.9%; --input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%; --ring: 240 4.9% 83.9%;
--radius: 0.5rem; --radius: 0.5rem;
--color-1: 0 100% 63%;
--color-2: 270 100% 63%;
--color-3: 210 100% 63%;
--color-4: 195 100% 63%;
--color-5: 90 100% 63%;
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1; font-feature-settings: "rlig" 1, "calt" 1;
} }
:root { :root {
--sidebar-background: 0 0% 98%; --sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%; --sidebar-foreground: 240 5.3% 26.1%;
@@ -44,6 +51,7 @@
--sidebar-border: 220 13% 91%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
} }
.dark { .dark {
--sidebar-background: 240 5.9% 10%; --sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%; --sidebar-foreground: 240 4.8% 95.9%;
@@ -54,4 +62,4 @@
--sidebar-border: 240 3.7% 15.9%; --sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
} }
} }

View File

@@ -1,11 +1,14 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Inter } from 'next/font/google' import { Montserrat } from 'next/font/google'
import './globals.css' import './globals.css'
import ConvexClientProvider from './ConvexClientProvider' import ConvexClientProvider from './ConvexClientProvider'
import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server"; import { ConvexAuthNextjsServerProvider } from "@convex-dev/auth/nextjs/server";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
const inter = Inter({ subsets: ['latin'] }) const montserrat = Montserrat({
subsets: ['latin'],
weight: ['300', '400', '500', '600', '700'],
})
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Sanati - Find Product Opportunities', title: 'Sanati - Find Product Opportunities',
@@ -20,7 +23,7 @@ export default function RootLayout({
return ( return (
<ConvexAuthNextjsServerProvider> <ConvexAuthNextjsServerProvider>
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={inter.className}> <body className={montserrat.className}>
<ThemeProvider <ThemeProvider
attribute="class" attribute="class"
defaultTheme="dark" defaultTheme="dark"

View File

@@ -1,121 +1,165 @@
import Link from 'next/link' import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ArrowRight, Search, Zap, Target, Sparkles } from 'lucide-react' import { ArrowRight, Search, Zap, Target, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DotPattern } from '@/components/magicui/dot-pattern'
import { HeroShader } from '@/components/hero-shader'
import { cn } from '@/lib/utils'
export default function LandingPage() { export default function LandingPage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="relative min-h-screen bg-background overflow-hidden font-sans">
{/* Header */} <DotPattern
<header className="border-b border-border"> className={cn(
<div className="container mx-auto max-w-6xl px-4 py-4 flex items-center justify-between"> "[mask-image:radial-gradient(1000px_circle_at_center,white,transparent)]",
<div className="flex items-center gap-2"> "opacity-50"
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground"> )}
<Search className="h-4 w-4" /> />
</div>
<span className="font-semibold text-foreground">Sanati</span> <HeroShader />
</div>
<nav className="flex items-center gap-4"> {/* Header */}
<Link href="/dashboard" className="text-sm text-muted-foreground hover:text-foreground"> <header className="border-b border-border/40 backdrop-blur-md sticky top-0 z-50">
Dashboard <div className="container mx-auto max-w-7xl px-4 py-4 flex items-center justify-between">
</Link> <div className="flex items-center gap-2">
<Link href="/auth"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Button size="sm">Get Started</Button> <Search className="h-4 w-4" />
</Link> </div>
</nav> <span className="font-semibold text-foreground tracking-tight">Sanati</span>
</div>
<nav className="flex items-center gap-4">
<Link href="/dashboard" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
Dashboard
</Link>
<Link href="/auth">
<Button size="sm" className="font-medium">Get Started</Button>
</Link>
</nav>
</div>
</header>
{/* Header */}
<header className="border-b border-border/40 backdrop-blur-md sticky top-0 z-50">
<div className="container mx-auto max-w-7xl px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Search className="h-4 w-4" />
</div>
<span className="font-semibold text-foreground tracking-tight">Sanati</span>
</div>
<nav className="flex items-center gap-4">
<Link href="/dashboard" className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
Dashboard
</Link>
<Link href="/auth">
<Button size="sm" className="font-medium">Get Started</Button>
</Link>
</nav>
</div>
</header>
{/* Hero */}
<section className="container mx-auto max-w-7xl px-4 min-h-[75vh] flex items-center relative z-10">
<div className="grid lg:grid-cols-2 gap-12 items-center w-full">
<div className="flex flex-col items-start text-left space-y-8">
<div className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80">
<Sparkles className="mr-1 h-3 w-3" />
AI-Powered Research
</div>
<h1 className="text-4xl font-light tracking-tight sm:text-5xl lg:text-7xl text-foreground">
Find Your Next
<br />
<span className="font-bold">Customers.</span>
</h1>
<p className="max-w-xl text-lg text-muted-foreground font-light leading-relaxed">
Sanati analyzes your product and finds people on Reddit, Hacker News, and forums
who are actively expressing needs that your solution solves.
</p>
<div className="flex flex-col sm:flex-row gap-4 pt-2">
<Link href="/auth">
<Button size="lg" className="gap-2 px-8 text-base">
Start Finding Opportunities
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
{/* Right side is reserved for the shader visualization */}
<div className="hidden lg:block h-full min-h-[400px]"></div>
</div>
</section>
{/* Features */}
<section className="border-t border-border/40 bg-muted/20 backdrop-blur-sm relative z-10">
<div className="container mx-auto max-w-7xl px-4 py-24">
<div className="grid md:grid-cols-3 gap-8">
<div className="bg-card text-card-foreground p-6 rounded-xl border h-full w-full flex flex-col items-start text-left">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 mb-4">
<Zap className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">AI Analysis</h3>
<p className="text-muted-foreground text-sm">
We scrape your website and use GPT-4 to extract features, pain points, and keywords automatically.
</p>
</div>
<div className="bg-card text-card-foreground p-6 rounded-xl border h-full w-full flex flex-col items-start text-left">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 mb-4">
<Search className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">Smart Dorking</h3>
<p className="text-muted-foreground text-sm">
Our system generates targeted Google dork queries to find high-intent posts across Reddit, HN, and more.
</p>
</div>
<div className="bg-card text-card-foreground p-6 rounded-xl border h-full w-full flex flex-col items-start text-left">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 mb-4">
<Target className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">Scored Leads</h3>
<p className="text-muted-foreground text-sm">
Each opportunity is scored by relevance and comes with suggested engagement approaches.
</p>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="container mx-auto max-w-7xl px-4 py-24 relative z-10">
<div className="rounded-3xl border border-border bg-gradient-to-b from-muted/50 to-muted/10 p-12 text-center relative overflow-hidden">
<div className="relative z-10">
<h2 className="text-3xl font-bold text-foreground mb-4">
Ready to find your customers?
</h2>
<p className="text-muted-foreground mb-8 max-w-lg mx-auto">
Stop guessing. Start finding people who are already looking for solutions like yours.
</p>
<Link href="/auth">
<Button size="default" className="px-6">Get Started Free</Button>
</Link>
</div>
<DotPattern
className={cn(
"[mask-image:radial-gradient(400px_circle_at_center,white,transparent)]",
"opacity-50"
)}
/>
</div>
</section>
{/* Footer */}
<footer className="border-t border-border/40 backdrop-blur-sm relative z-10">
<div className="container mx-auto max-w-7xl px-4 py-8">
<p className="text-center text-sm text-muted-foreground">
Sanati Built for indie hackers and founders
</p>
</div>
</footer>
</div> </div>
</header> )
{/* Hero */}
<section className="container mx-auto max-w-6xl px-4 py-24 lg:py-32">
<div className="flex flex-col items-center text-center space-y-8">
<Badge variant="secondary" className="px-4 py-1.5">
<Sparkles className="mr-1 h-3 w-3" />
AI-Powered Research
</Badge>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl text-foreground">
Find Your Next Customers
<br />
<span className="text-muted-foreground">Before They Know They Need You</span>
</h1>
<p className="max-w-2xl text-lg text-muted-foreground">
Sanati analyzes your product and finds people on Reddit, Hacker News, and forums
who are actively expressing needs that your solution solves.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/auth">
<Button size="lg" className="gap-2">
Start Finding Opportunities
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
</div>
</div>
</section>
{/* Features */}
<section className="border-t border-border bg-muted/50">
<div className="container mx-auto max-w-6xl px-4 py-24">
<div className="grid md:grid-cols-3 gap-8">
<div className="space-y-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Zap className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-semibold text-foreground">AI Analysis</h3>
<p className="text-muted-foreground">
We scrape your website and use GPT-4 to extract features, pain points, and keywords automatically.
</p>
</div>
<div className="space-y-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Search className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-semibold text-foreground">Smart Dorking</h3>
<p className="text-muted-foreground">
Our system generates targeted Google dork queries to find high-intent posts across Reddit, HN, and more.
</p>
</div>
<div className="space-y-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<Target className="h-5 w-5 text-primary" />
</div>
<h3 className="text-lg font-semibold text-foreground">Scored Leads</h3>
<p className="text-muted-foreground">
Each opportunity is scored by relevance and comes with suggested engagement approaches.
</p>
</div>
</div>
</div>
</section>
{/* CTA */}
<section className="container mx-auto max-w-6xl px-4 py-24">
<div className="rounded-2xl border border-border bg-muted/50 p-12 text-center">
<h2 className="text-3xl font-bold text-foreground mb-4">
Ready to find your customers?
</h2>
<p className="text-muted-foreground mb-8 max-w-lg mx-auto">
Stop guessing. Start finding people who are already looking for solutions like yours.
</p>
<Link href="/auth">
<Button size="lg">Get Started Free</Button>
</Link>
</div>
</section>
{/* Footer */}
<footer className="border-t border-border">
<div className="container mx-auto max-w-6xl px-4 py-8">
<p className="text-center text-sm text-muted-foreground">
Sanati Built for indie hackers and founders
</p>
</div>
</footer>
</div>
)
} }

120
components/hero-shader.tsx Normal file
View File

@@ -0,0 +1,120 @@
"use client";
import { useEffect, useRef } from "react";
export function HeroShader() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let t = 0;
let animationFrameId: number;
const resize = () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
};
resize();
window.addEventListener("resize", resize);
const draw = () => {
// Clear canvas with some transparency for trails?
// Or just clear completely given the math looks like it redraws?
// Tweet says background(9), which is very dark.
// Tweetcarts usually redraw fully or rely on trails.
// Given complexity, let's clear fully first.
ctx.fillStyle = "#090909"; // background(9) approximation
// ctx.fillRect(0, 0, canvas.width, canvas.height);
// Actually, let's make it transparent so it blends with our site
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "rgba(255, 255, 255, 0.5)"; // stroke(w, 116) approx white with alpha
// Center of drawing - offset to the right
const cx = canvas.width * 0.75;
const cy = canvas.height / 2;
// Loop for points
// for(t+=PI/90,i=1e4;i--;)a()
t += Math.PI / 90;
for (let i = 10000; i > 0; i--) {
// y = i / 790
let y = i / 790;
// k = (y<8 ? 9+sin(y^9)*6 : 4+cos(y)) * cos(i+t/4)
// Note: processing sin(y^9) is bitwise XOR in JS, but usually in math it's power?
// "y^9" in many tweetcarts (JS) is XOR if it's straight JS evaluation,
// but if it's GLSL it might mean pow.
// Given "tweetcart", it's likely JS/Processing, where ^ is XOR in standard JS but often used as Pow in math context?
// Processing language: ^ is bitwise XOR. pow(n, e) is power.
// Let's assume XOR as it's common in condensed code.
// Wait, standard JS `^` is XOR.
let k_term1 = (y < 8)
? (9 + Math.sin(y ** 9) * 6) // Trying Power first as it produces curves
: (4 + Math.cos(y));
// Wait, dweet/tweetcart usually use JS syntax.
// Let's try to replicate exact syntax logic.
// Logic: k = (condition) * cos(i + t/4)
// Re-evaluating y^9. If y is float, XOR converts to int.
// y = i / 790, which is float.
// Let's stick to Math.pow for smoother graphs if intended.
// But let's try standard JS behavior for ^ (XOR) might be intended for glitchy look?
// Let's try power first.
const k = ((y < 8) ? (9 + Math.sin(Math.pow(y, 9)) * 6) : (4 + Math.cos(y))) * Math.cos(i + t / 4);
// e = y/3 - 13 + cos(e ?? no, that was likely comma operator)
// Original: d=mag(k=(...), e=y/3-13) + cos(e+t*2+i%2*4)
// mag(a, b) = sqrt(a*a + b*b)
// So args to mag are k and e.
const e = y / 3 - 13;
const mag_ke = Math.sqrt(k * k + e * e);
// d = mag(...) + cos(e + t*2 + i%2*4)
const d = mag_ke + Math.cos(e + t * 2 + (i % 2) * 4);
// c = d/4 - t/2 + i%2*3
const c = d / 4 - t / 2 + (i % 2) * 3;
// q = y * k / 5 * (2 + sin(d*2 + y - t*4)) + 80
const q = y * k / 5 * (2 + Math.sin(d * 2 + y - t * 4)) + 80;
// x = q * cos(c) + 200
// y_out = q * sin(c) + d * 9 + 60
// 200 and 60 are likely offsets for 400x400 canvas.
// We should center it.
const x = (q * Math.cos(c)) + cx;
const y_out = (q * Math.sin(c) + d * 9) + cy;
ctx.fillRect(x, y_out, 1.5, 1.5);
}
animationFrameId = requestAnimationFrame(draw);
};
draw();
return () => {
window.removeEventListener("resize", resize);
cancelAnimationFrame(animationFrameId);
};
}, []);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full opacity-60 mix-blend-screen pointer-events-none"
/>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
interface BlurInProps {
word: string;
className?: string;
variant?: {
hidden: { filter: string; opacity: number };
visible: { filter: string; opacity: number };
};
duration?: number;
}
export function BlurIn({
word,
className,
variant,
duration = 1,
}: BlurInProps) {
const defaultVariants = {
hidden: { filter: "blur(10px)", opacity: 0 },
visible: { filter: "blur(0px)", opacity: 1 },
};
const combinedVariants = variant || defaultVariants;
return (
<motion.h1
initial="hidden"
animate="visible"
transition={{ duration }}
variants={combinedVariants}
className={cn(
"font-display text-center text-4xl font-bold tracking-[-0.02em] drop-shadow-sm md:text-7xl md:leading-[5rem]",
className,
)}
>
{word}
</motion.h1>
);
}

View File

@@ -0,0 +1,55 @@
import { useId } from "react";
import { cn } from "@/lib/utils";
interface DotPatternProps {
width?: any;
height?: any;
x?: any;
y?: any;
cx?: any;
cy?: any;
cr?: any;
className?: string;
[key: string]: any;
}
export function DotPattern({
width = 16,
height = 16,
x = 0,
y = 0,
cx = 1,
cy = 1,
cr = 1,
className,
...props
}: DotPatternProps) {
const id = useId();
return (
<svg
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 h-full w-full fill-neutral-400/80",
className,
)}
{...props}
>
<defs>
<pattern
id={id}
width={width}
height={height}
patternUnits="userSpaceOnUse"
patternContentUnits="userSpaceOnUse"
x={x}
y={y}
>
<circle id="pattern-circle" cx={cx} cy={cy} r={cr} />
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
</svg>
);
}

View File

@@ -0,0 +1,24 @@
import React from "react";
import { cn } from "@/lib/utils";
interface RainbowButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> { }
export function RainbowButton({
children,
className,
...props
}: RainbowButtonProps) {
return (
<button
className={cn(
"group relative inline-flex h-11 animate-rainbow cursor-pointer items-center justify-center rounded-xl border-0 bg-[length:200%] px-8 py-2 font-medium text-primary-foreground transition-colors [background-image:linear-gradient(90deg,hsl(var(--color-1)),hsl(var(--color-5)),hsl(var(--color-3)),hsl(var(--color-4)),hsl(var(--color-2)))] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
>
{children}
</button>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { cn } from "@/lib/utils";
type TColorProp = string | string[];
interface ShineBorderProps {
borderRadius?: number;
borderWidth?: number;
duration?: number;
color?: TColorProp;
className?: string;
children: React.ReactNode;
}
/**
* @name Shine Border
* @description It is an animated background border effect component with easy to use and configurable props.
* @param borderRadius defines the radius of the border.
* @param borderWidth defines the width of the border.
* @param duration defines the animation duration to be applied on the shining border
* @param color a string or string array to define border color.
* @param className defines the class name to be applied to the component
* @param children nodes to be rendered in the center of the component
*/
export function ShineBorder({
borderRadius = 8,
borderWidth = 1,
duration = 14,
color = "#000000",
className,
children,
}: ShineBorderProps) {
return (
<div
style={
{
"--border-radius": `${borderRadius}px`,
} as React.CSSProperties
}
className={cn(
"relative min-h-[60px] w-fit min-w-[300px] place-items-center rounded-[--border-radius] bg-white p-3 text-black dark:bg-black dark:text-white",
className,
)}
>
<div
style={
{
"--border-width": `${borderWidth}px`,
"--border-radius": `${borderRadius}px`,
"--duration": `${duration}s`,
"--mask-linear-gradient": `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
"--background-radial-gradient": `radial-gradient(transparent,transparent, ${Array.isArray(color) ? color.join(",") : color
},transparent,transparent)`,
} as React.CSSProperties
}
className={`before:bg-shine-size before:absolute before:inset-0 before:aspect-square before:size-full before:rounded-[--border-radius] before:p-[--border-width] before:will-change-[background-position] before:content-[""] before:![-webkit-mask-composite:xor] before:![mask-composite:exclude] before:[background-image:--background-radial-gradient] before:[background-size:300%_300%] before:[mask:--mask-linear-gradient] motion-safe:before:animate-shine`}
></div>
{children}
</div>
);
}

View File

@@ -0,0 +1,48 @@
"use client";
import { cn } from "@/lib/utils";
import { AnimatePresence, HTMLMotionProps, motion } from "framer-motion";
import { useEffect, useState } from "react";
interface WordRotateProps {
words: string[];
duration?: number;
framerProps?: HTMLMotionProps<"h1">;
className?: string;
}
export function WordRotate({
words,
duration = 2500,
framerProps = {
initial: { opacity: 0, y: -50 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 50 },
transition: { duration: 0.25, ease: "easeOut" },
},
className,
}: WordRotateProps) {
const [index, setIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setIndex((prevIndex) => (prevIndex + 1) % words.length);
}, duration);
return () => clearInterval(interval);
}, [words, duration]);
return (
<div className="overflow-hidden py-2">
<AnimatePresence mode="wait">
<motion.h1
key={words[index]}
className={cn(className)}
{...framerProps}
>
{words[index]}
</motion.h1>
</AnimatePresence>
</div>
);
}

View File

@@ -2,7 +2,7 @@
export type PlatformId = 'reddit' | 'twitter' | 'hackernews' | 'indiehackers' | 'quora' | 'stackoverflow' | 'linkedin' export type PlatformId = 'reddit' | 'twitter' | 'hackernews' | 'indiehackers' | 'quora' | 'stackoverflow' | 'linkedin'
export type SearchStrategy = export type SearchStrategy =
| 'direct-keywords' | 'direct-keywords'
| 'problem-pain' | 'problem-pain'
| 'competitor-alternative' | 'competitor-alternative'
@@ -43,30 +43,30 @@ export interface Opportunity {
snippet: string snippet: string
platform: string platform: string
source: string source: string
relevanceScore: number relevanceScore: number
emotionalIntensity: 'low' | 'medium' | 'high' emotionalIntensity: 'low' | 'medium' | 'high'
intent: 'frustrated' | 'looking' | 'comparing' | 'learning' | 'recommending' intent: 'frustrated' | 'looking' | 'comparing' | 'learning' | 'recommending'
matchedKeywords: string[] matchedKeywords: string[]
matchedProblems: string[] matchedProblems: string[]
matchedPersona?: string matchedPersona?: string
engagement?: { engagement?: {
upvotes?: number upvotes?: number
comments?: number comments?: number
views?: number views?: number
} }
postedAt?: string postedAt?: string
status: 'new' | 'viewed' | 'contacted' | 'responded' | 'converted' | 'ignored' status: 'new' | 'viewed' | 'contacted' | 'responded' | 'converted' | 'ignored'
notes?: string notes?: string
tags?: string[] tags?: string[]
suggestedApproach: string suggestedApproach: string
replyTemplate?: string replyTemplate?: string
softPitch: boolean softPitch: boolean
scoringBreakdown?: { scoringBreakdown?: {
keywordMatches: number keywordMatches: number
problemMatches: number problemMatches: number
@@ -145,7 +145,7 @@ export interface EnhancedProductAnalysis {
description: string description: string
category: string category: string
positioning: string positioning: string
features: Feature[] features: Feature[]
problemsSolved: Problem[] problemsSolved: Problem[]
personas: Persona[] personas: Persona[]
@@ -153,7 +153,7 @@ export interface EnhancedProductAnalysis {
useCases: UseCase[] useCases: UseCase[]
competitors: Competitor[] competitors: Competitor[]
dorkQueries: DorkQuery[] dorkQueries: DorkQuery[]
scrapedAt: string scrapedAt: string
analysisVersion: string analysisVersion: string
} }

92
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",
@@ -23,21 +24,25 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@supabase/ssr": "^0.8.0", "@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.93.3", "@supabase/supabase-js": "^2.93.3",
"animejs": "^4.3.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.0", "clsx": "^2.1.1",
"convex": "^1.31.7", "convex": "^1.31.7",
"framer-motion": "^12.31.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"mini-svg-data-uri": "^1.4.4",
"next": "14.1.0", "next": "14.1.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^4.28.0", "openai": "^4.28.0",
"puppeteer": "^22.0.0", "puppeteer": "^22.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"tailwind-merge": "^2.2.0", "tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/animejs": "^3.1.13",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",
@@ -1239,6 +1244,15 @@
} }
} }
}, },
"node_modules/@radix-ui/react-icons": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
"integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==",
"license": "MIT",
"peerDependencies": {
"react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/@radix-ui/react-id": { "node_modules/@radix-ui/react-id": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
@@ -1949,6 +1963,13 @@
"version": "0.23.0", "version": "0.23.0",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/animejs": {
"version": "3.1.13",
"resolved": "https://registry.npmjs.org/@types/animejs/-/animejs-3.1.13.tgz",
"integrity": "sha512-yWg9l1z7CAv/TKpty4/vupEh24jDGUZXv4r26StRkpUPQm04ztJaftgpto8vwdFs8SiTq6XfaPKCSI+wjzNMvQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.30", "version": "20.19.30",
"license": "MIT", "license": "MIT",
@@ -2036,6 +2057,16 @@
"node": ">= 8.0.0" "node": ">= 8.0.0"
} }
}, },
"node_modules/animejs": {
"version": "4.3.5",
"resolved": "https://registry.npmjs.org/animejs/-/animejs-4.3.5.tgz",
"integrity": "sha512-yuQo/r97TCE+DDu3dTRKjyhBKSEGBcZorWeRW7WCE7EkAQpBoNd2E82dAAD/MDdrbREv7qsw/u7MAqiUX544WQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/juliangarnier"
}
},
"node_modules/ansi-regex": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"license": "MIT", "license": "MIT",
@@ -2488,6 +2519,8 @@
}, },
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@@ -2979,6 +3012,33 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "12.31.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.31.0.tgz",
"integrity": "sha512-Tnd0FU05zGRFI3JJmBegXonF1rfuzYeuXd1QSdQ99Ysnppk0yWBWSW2wUsqzRpS5nv0zPNx+y0wtDj4kf0q5RQ==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.30.1",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3426,10 +3486,34 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==",
"license": "MIT",
"bin": {
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/mitt": { "node_modules/mitt": {
"version": "3.0.1", "version": "3.0.1",
"license": "MIT" "license": "MIT"
}, },
"node_modules/motion-dom": {
"version": "12.30.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.30.1.tgz",
"integrity": "sha512-QXB+iFJRzZTqL+Am4a1CRoHdH+0Nq12wLdqQQZZsfHlp9AMt6PA098L/61oVZsDA+Ep3QSGudzpViyRrhYhGcQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"license": "MIT" "license": "MIT"
@@ -4360,7 +4444,9 @@
} }
}, },
"node_modules/tailwind-merge": { "node_modules/tailwind-merge": {
"version": "2.6.0", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
"integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",

View File

@@ -16,6 +16,7 @@
"@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slider": "^1.1.2",
@@ -24,21 +25,25 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@supabase/ssr": "^0.8.0", "@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.93.3", "@supabase/supabase-js": "^2.93.3",
"animejs": "^4.3.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.0", "clsx": "^2.1.1",
"convex": "^1.31.7", "convex": "^1.31.7",
"framer-motion": "^12.31.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"mini-svg-data-uri": "^1.4.4",
"next": "14.1.0", "next": "14.1.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^4.28.0", "openai": "^4.28.0",
"puppeteer": "^22.0.0", "puppeteer": "^22.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"tailwind-merge": "^2.2.0", "tailwind-merge": "^2.6.1",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/animejs": "^3.1.13",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/react": "^18.2.0", "@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",
@@ -47,4 +52,4 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
} }

View File

@@ -1,97 +1,114 @@
import type { Config } from "tailwindcss" import type { Config } from "tailwindcss"
const config: Config = { const config: Config = {
darkMode: ["class"], darkMode: ["class"],
content: [ content: [
"./pages/**/*.{ts,tsx}", "./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}",
], ],
theme: { theme: {
container: { container: {
center: true, center: true,
padding: '2rem', padding: '2rem',
screens: { screens: {
'2xl': '1400px' '2xl': '1400px'
} }
}, },
extend: { extend: {
colors: { colors: {
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))', background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))', foreground: 'hsl(var(--foreground))',
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))' foreground: 'hsl(var(--primary-foreground))'
}, },
secondary: { secondary: {
DEFAULT: 'hsl(var(--secondary))', DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))' foreground: 'hsl(var(--secondary-foreground))'
}, },
destructive: { destructive: {
DEFAULT: 'hsl(var(--destructive))', DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))' foreground: 'hsl(var(--destructive-foreground))'
}, },
muted: { muted: {
DEFAULT: 'hsl(var(--muted))', DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))' foreground: 'hsl(var(--muted-foreground))'
}, },
accent: { accent: {
DEFAULT: 'hsl(var(--accent))', DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))' foreground: 'hsl(var(--accent-foreground))'
}, },
popover: { popover: {
DEFAULT: 'hsl(var(--popover))', DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))' foreground: 'hsl(var(--popover-foreground))'
}, },
card: { card: {
DEFAULT: 'hsl(var(--card))', DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))' foreground: 'hsl(var(--card-foreground))'
}, },
sidebar: { sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))', DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))', foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))', primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', 'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))', accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', 'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))', border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))' ring: 'hsl(var(--sidebar-ring))'
} }
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)', md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)' sm: 'calc(var(--radius) - 4px)'
}, },
keyframes: { keyframes: {
'accordion-down': { 'accordion-down': {
from: { from: {
height: '0' height: '0'
}, },
to: { to: {
height: 'var(--radix-accordion-content-height)' height: 'var(--radix-accordion-content-height)'
} }
}, },
'accordion-up': { 'accordion-up': {
from: { from: {
height: 'var(--radix-accordion-content-height)' height: 'var(--radix-accordion-content-height)'
}, },
to: { to: {
height: '0' height: '0'
} }
} },
}, rainbow: {
animation: { "0%": { "background-position": "0%" },
'accordion-down': 'accordion-down 0.2s ease-out', "100%": { "background-position": "200%" },
'accordion-up': 'accordion-up 0.2s ease-out' },
} shine: {
} "0%": {
}, "background-position": "0% 0%",
plugins: [require("tailwindcss-animate")], },
"50%": {
"background-position": "100% 100%",
},
to: {
"background-position": "0% 0%",
},
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
rainbow: "rainbow var(--speed, 2s) infinite linear",
shine: "shine var(--duration) infinite linear",
}
}
},
plugins: [require("tailwindcss-animate")],
} }
export default config export default config