Compare commits
3 Commits
f1e13f87f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 025ce8f763 | |||
| 6d271ef65b | |||
| b0ef60ff32 |
@@ -5,7 +5,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
FROM base AS deps
|
FROM base AS deps
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci --include=dev
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export async function POST(request: NextRequest) {
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
error: "OpenAI API key not configured",
|
error: "OpenAI API key not configured",
|
||||||
timeline: timeline.map((item) =>
|
timeline: timeline.map((item) =>
|
||||||
item.status === "running" ? { ...item, status: "failed" } : item
|
item.status === "running" ? { ...item, status: "failed" as const } : item
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ token }
|
{ token }
|
||||||
@@ -259,7 +259,7 @@ export async function POST(request: NextRequest) {
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
error: error.message || "Manual analysis failed",
|
error: error.message || "Manual analysis failed",
|
||||||
timeline: timeline.map((item) =>
|
timeline: timeline.map((item) =>
|
||||||
item.status === "running" ? { ...item, status: "failed" } : item
|
item.status === "running" ? { ...item, status: "failed" as const } : item
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ token }
|
{ token }
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export async function POST(request: NextRequest) {
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
error: "OpenAI API key not configured",
|
error: "OpenAI API key not configured",
|
||||||
timeline: timeline.map((item) =>
|
timeline: timeline.map((item) =>
|
||||||
item.status === "running" ? { ...item, status: "failed" } : item
|
item.status === "running" ? { ...item, status: "failed" as const } : item
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ token }
|
{ token }
|
||||||
@@ -270,7 +270,7 @@ export async function POST(request: NextRequest) {
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
error: error.message || "Analysis failed",
|
error: error.message || "Analysis failed",
|
||||||
timeline: timeline.map((item) =>
|
timeline: timeline.map((item) =>
|
||||||
item.status === "running" ? { ...item, status: "failed" } : item
|
item.status === "running" ? { ...item, status: "failed" as const } : item
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ token }
|
{ token }
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Checkout } from "@polar-sh/nextjs";
|
import { Checkout } from "@polar-sh/nextjs";
|
||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
export const GET = async () => {
|
export const GET = async (request: NextRequest) => {
|
||||||
if (!process.env.POLAR_ACCESS_TOKEN || !process.env.POLAR_SUCCESS_URL) {
|
if (!process.env.POLAR_ACCESS_TOKEN || !process.env.POLAR_SUCCESS_URL) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
@@ -17,5 +17,5 @@ export const GET = async () => {
|
|||||||
successUrl: process.env.POLAR_SUCCESS_URL,
|
successUrl: process.env.POLAR_SUCCESS_URL,
|
||||||
});
|
});
|
||||||
|
|
||||||
return handler();
|
return handler(request);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const searchSchema = z.object({
|
|||||||
custom: z.boolean().optional()
|
custom: z.boolean().optional()
|
||||||
})),
|
})),
|
||||||
strategies: z.array(z.string()),
|
strategies: z.array(z.string()),
|
||||||
maxResults: z.number().default(50)
|
maxResults: z.number().default(50),
|
||||||
minAgeDays: z.number().min(0).max(365).optional(),
|
minAgeDays: z.number().min(0).max(365).optional(),
|
||||||
maxAgeDays: z.number().min(0).max(365).optional()
|
maxAgeDays: z.number().min(0).max(365).optional()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const bodySchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
let ageFilters: SerperAgeFilter | undefined
|
||||||
try {
|
try {
|
||||||
const requestId = request.headers.get("x-request-id") ?? undefined;
|
const requestId = request.headers.get("x-request-id") ?? undefined;
|
||||||
if (!(await isAuthenticatedNextjs())) {
|
if (!(await isAuthenticatedNextjs())) {
|
||||||
@@ -51,7 +52,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { analysis, minAgeDays, maxAgeDays } = bodySchema.parse(body)
|
const { analysis, minAgeDays, maxAgeDays } = bodySchema.parse(body)
|
||||||
const ageFilters: SerperAgeFilter = {
|
ageFilters = {
|
||||||
minAgeDays,
|
minAgeDays,
|
||||||
maxAgeDays,
|
maxAgeDays,
|
||||||
}
|
}
|
||||||
@@ -287,15 +288,15 @@ async function analyzeOpportunities(
|
|||||||
const relevanceScore = Math.min(keywordScore + problemScore, 1)
|
const relevanceScore = Math.min(keywordScore + problemScore, 1)
|
||||||
|
|
||||||
// Determine intent
|
// Determine intent
|
||||||
let intent: Opportunity['intent'] = 'looking-for'
|
let intent: Opportunity['intent'] = 'looking'
|
||||||
if (content.includes('frustrated') || content.includes('hate') || content.includes('sucks')) {
|
if (content.includes('frustrated') || content.includes('hate') || content.includes('sucks')) {
|
||||||
intent = 'frustrated'
|
intent = 'frustrated'
|
||||||
} else if (content.includes('alternative') || content.includes('switching')) {
|
} else if (content.includes('alternative') || content.includes('switching')) {
|
||||||
intent = 'alternative'
|
intent = 'comparing'
|
||||||
} else if (content.includes('vs') || content.includes('comparison') || content.includes('better')) {
|
} else if (content.includes('vs') || content.includes('comparison') || content.includes('better')) {
|
||||||
intent = 'comparison'
|
intent = 'comparing'
|
||||||
} else if (content.includes('how to') || content.includes('fix') || content.includes('solution')) {
|
} else if (content.includes('how to') || content.includes('fix') || content.includes('solution')) {
|
||||||
intent = 'problem-solving'
|
intent = 'learning'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find matching persona
|
// Find matching persona
|
||||||
@@ -305,16 +306,21 @@ async function analyzeOpportunities(
|
|||||||
|
|
||||||
if (relevanceScore >= 0.3) {
|
if (relevanceScore >= 0.3) {
|
||||||
opportunities.push({
|
opportunities.push({
|
||||||
|
id: result.url,
|
||||||
title: result.title,
|
title: result.title,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
|
platform: result.source,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
snippet: result.snippet.slice(0, 300),
|
snippet: result.snippet.slice(0, 300),
|
||||||
relevanceScore,
|
relevanceScore,
|
||||||
painPoints: matchedProblems.slice(0, 3),
|
|
||||||
suggestedApproach: generateApproach(intent, analysis.productName),
|
|
||||||
matchedKeywords: matchedKeywords.slice(0, 5),
|
matchedKeywords: matchedKeywords.slice(0, 5),
|
||||||
|
matchedProblems: matchedProblems.slice(0, 3),
|
||||||
matchedPersona,
|
matchedPersona,
|
||||||
intent
|
intent,
|
||||||
|
emotionalIntensity: intent === 'frustrated' ? 'high' : matchedProblems.length > 0 ? 'medium' : 'low',
|
||||||
|
status: 'new',
|
||||||
|
suggestedApproach: generateApproach(intent, analysis.productName),
|
||||||
|
softPitch: false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ import type {
|
|||||||
} from '@/lib/types'
|
} from '@/lib/types'
|
||||||
import { estimateSearchTime } from '@/lib/query-generator'
|
import { estimateSearchTime } from '@/lib/query-generator'
|
||||||
|
|
||||||
|
const AGE_OPTIONS = [0, 1, 3, 7, 14, 30, 60, 90, 180, 365]
|
||||||
|
|
||||||
const STRATEGY_INFO: Record<SearchStrategy, { name: string; description: string }> = {
|
const STRATEGY_INFO: Record<SearchStrategy, { name: string; description: string }> = {
|
||||||
'direct-keywords': { name: 'Direct Keywords', description: 'People looking for your product category' },
|
'direct-keywords': { name: 'Direct Keywords', description: 'People looking for your product category' },
|
||||||
'problem-pain': { name: 'Problem/Pain', description: 'People experiencing problems you solve' },
|
'problem-pain': { name: 'Problem/Pain', description: 'People experiencing problems you solve' },
|
||||||
@@ -176,21 +178,21 @@ const GOAL_PRESETS: {
|
|||||||
title: "High-intent leads",
|
title: "High-intent leads",
|
||||||
description: "Shortlist people actively searching to buy or switch.",
|
description: "Shortlist people actively searching to buy or switch.",
|
||||||
strategies: ["direct-keywords", "competitor-alternative", "comparison", "recommendation"],
|
strategies: ["direct-keywords", "competitor-alternative", "comparison", "recommendation"],
|
||||||
maxQueries: 30,
|
maxQueries: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "pain-first",
|
id: "pain-first",
|
||||||
title: "Problem pain",
|
title: "Problem pain",
|
||||||
description: "Find people expressing frustration or blockers.",
|
description: "Find people expressing frustration or blockers.",
|
||||||
strategies: ["problem-pain", "emotional-frustrated"],
|
strategies: ["problem-pain", "emotional-frustrated"],
|
||||||
maxQueries: 40,
|
maxQueries: 15,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "market-scan",
|
id: "market-scan",
|
||||||
title: "Market scan",
|
title: "Market scan",
|
||||||
description: "Broader sweep to map demand and platforms.",
|
description: "Broader sweep to map demand and platforms.",
|
||||||
strategies: ["direct-keywords", "problem-pain", "how-to", "recommendation"],
|
strategies: ["direct-keywords", "problem-pain", "how-to", "recommendation"],
|
||||||
maxQueries: 50,
|
maxQueries: 20,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -208,7 +210,7 @@ export default function OpportunitiesPage() {
|
|||||||
'problem-pain',
|
'problem-pain',
|
||||||
'competitor-alternative'
|
'competitor-alternative'
|
||||||
])
|
])
|
||||||
const [maxQueries, setMaxQueries] = useState(50)
|
const [maxQueries, setMaxQueries] = useState(20)
|
||||||
const [minAgeDays, setMinAgeDays] = useState(0)
|
const [minAgeDays, setMinAgeDays] = useState(0)
|
||||||
const [maxAgeDays, setMaxAgeDays] = useState(30)
|
const [maxAgeDays, setMaxAgeDays] = useState(30)
|
||||||
const [goalPreset, setGoalPreset] = useState<string>('high-intent')
|
const [goalPreset, setGoalPreset] = useState<string>('high-intent')
|
||||||
@@ -338,7 +340,7 @@ export default function OpportunitiesPage() {
|
|||||||
setStrategies(parsed.strategies)
|
setStrategies(parsed.strategies)
|
||||||
}
|
}
|
||||||
if (typeof parsed.maxQueries === 'number') {
|
if (typeof parsed.maxQueries === 'number') {
|
||||||
setMaxQueries(Math.min(Math.max(parsed.maxQueries, 10), 50))
|
setMaxQueries(Math.min(Math.max(parsed.maxQueries, 5), 20))
|
||||||
}
|
}
|
||||||
if (typeof parsed.goalPreset === 'string') {
|
if (typeof parsed.goalPreset === 'string') {
|
||||||
setGoalPreset(parsed.goalPreset)
|
setGoalPreset(parsed.goalPreset)
|
||||||
@@ -366,7 +368,7 @@ export default function OpportunitiesPage() {
|
|||||||
}
|
}
|
||||||
} else if (defaultPlatformsRef.current) {
|
} else if (defaultPlatformsRef.current) {
|
||||||
setStrategies(['direct-keywords', 'problem-pain', 'competitor-alternative'])
|
setStrategies(['direct-keywords', 'problem-pain', 'competitor-alternative'])
|
||||||
setMaxQueries(50)
|
setMaxQueries(20)
|
||||||
setGoalPreset('high-intent')
|
setGoalPreset('high-intent')
|
||||||
setPlatforms(defaultPlatformsRef.current)
|
setPlatforms(defaultPlatformsRef.current)
|
||||||
}
|
}
|
||||||
@@ -495,7 +497,7 @@ export default function OpportunitiesPage() {
|
|||||||
searchTemplate: platform.searchTemplate ?? "",
|
searchTemplate: platform.searchTemplate ?? "",
|
||||||
})),
|
})),
|
||||||
strategies,
|
strategies,
|
||||||
maxResults: Math.min(maxQueries, 50),
|
maxResults: Math.min(maxQueries, 20),
|
||||||
minAgeDays: minAgeDays > 0 ? minAgeDays : undefined,
|
minAgeDays: minAgeDays > 0 ? minAgeDays : undefined,
|
||||||
maxAgeDays: maxAgeDays > 0 ? maxAgeDays : undefined,
|
maxAgeDays: maxAgeDays > 0 ? maxAgeDays : undefined,
|
||||||
}
|
}
|
||||||
@@ -745,7 +747,68 @@ export default function OpportunitiesPage() {
|
|||||||
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div className="p-4 border-t border-border space-y-2">
|
<div className="p-4 border-t border-border space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium uppercase text-muted-foreground">Lead freshness</Label>
|
||||||
|
<div className="text-xs text-muted-foreground flex items-center justify-between">
|
||||||
|
<span>Set min/max age to target thread freshness</span>
|
||||||
|
<span>
|
||||||
|
{minAgeDays === 0 ? 'newest' : `${minAgeDays}+ days`} – {maxAgeDays > 0 ? `up to ${maxAgeDays} days` : 'any age'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Min age (older)</span>
|
||||||
|
<span>{minAgeDays} day{minAgeDays === 1 ? '' : 's'}</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={String(minAgeDays)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value)
|
||||||
|
const limited = maxAgeDays > 0 ? Math.min(next, maxAgeDays) : next
|
||||||
|
setMinAgeDays(limited)
|
||||||
|
}}
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{AGE_OPTIONS.map((value) => (
|
||||||
|
<option
|
||||||
|
key={`min-age-${value}`}
|
||||||
|
value={value}
|
||||||
|
disabled={maxAgeDays > 0 && value > maxAgeDays}
|
||||||
|
>
|
||||||
|
{value === 0 ? 'Newest (0 days)' : `${value} day${value === 1 ? '' : 's'}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Max age (newer)</span>
|
||||||
|
<span>{maxAgeDays > 0 ? `${maxAgeDays} days` : 'Any'}</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={String(maxAgeDays)}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value)
|
||||||
|
const adjusted = next === 0 ? 0 : Math.max(next, minAgeDays)
|
||||||
|
setMaxAgeDays(adjusted)
|
||||||
|
}}
|
||||||
|
className="h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{AGE_OPTIONS.map((value) => (
|
||||||
|
<option
|
||||||
|
key={`max-age-${value}`}
|
||||||
|
value={value}
|
||||||
|
disabled={value !== 0 && value < minAgeDays}
|
||||||
|
>
|
||||||
|
{value === 0 ? 'Any age' : `${value} day${value === 1 ? '' : 's'}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-medium uppercase text-muted-foreground">Search Volume</Label>
|
<Label className="text-sm font-medium uppercase text-muted-foreground">Search Volume</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -756,8 +819,8 @@ export default function OpportunitiesPage() {
|
|||||||
<Slider
|
<Slider
|
||||||
value={[maxQueries]}
|
value={[maxQueries]}
|
||||||
onValueChange={([v]) => setMaxQueries(v)}
|
onValueChange={([v]) => setMaxQueries(v)}
|
||||||
min={10}
|
min={5}
|
||||||
max={50}
|
max={20}
|
||||||
step={5}
|
step={5}
|
||||||
/>
|
/>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
@@ -783,9 +846,6 @@ export default function OpportunitiesPage() {
|
|||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>max {maxQueries} queries</span>
|
<span>max {maxQueries} queries</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Lead age window: {minAgeDays === 0 ? 'newest' : `${minAgeDays}+ days old`} – {maxAgeDays > 0 ? `up to ${maxAgeDays} days` : 'any age'}
|
|
||||||
</div>
|
|
||||||
{platforms.filter(p => p.enabled).length === 0 && (
|
{platforms.filter(p => p.enabled).length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground">Select at least one source to search.</p>
|
<p className="text-xs text-muted-foreground">Select at least one source to search.</p>
|
||||||
)}
|
)}
|
||||||
@@ -852,42 +912,6 @@ export default function OpportunitiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
|
||||||
<Label className="text-sm font-medium uppercase text-muted-foreground">Lead freshness</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Restrict opportunities by lead age. Set a maximum age to avoid archived threads and an optional minimum age to skip brand-new posts.
|
|
||||||
</p>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>Min age (older than)</span>
|
|
||||||
<span>{minAgeDays} day{minAgeDays === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[minAgeDays]}
|
|
||||||
onValueChange={([value]) => {
|
|
||||||
const limited = maxAgeDays > 0 ? Math.min(value, maxAgeDays) : value
|
|
||||||
setMinAgeDays(limited)
|
|
||||||
}}
|
|
||||||
min={0}
|
|
||||||
max={365}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
|
||||||
<span>Max age (newer than)</span>
|
|
||||||
<span>{maxAgeDays > 0 ? `${maxAgeDays} days` : 'Any'}</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[maxAgeDays]}
|
|
||||||
onValueChange={([value]) => {
|
|
||||||
const nextMax = Math.max(value, minAgeDays)
|
|
||||||
setMaxAgeDays(nextMax)
|
|
||||||
}}
|
|
||||||
min={0}
|
|
||||||
max={365}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Inter } 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'] })
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Sanati - Find Product Opportunities',
|
title: 'Sanati - Find Product Opportunities',
|
||||||
description: 'AI-powered product research and opportunity finding',
|
description: 'AI-powered product research and opportunity finding',
|
||||||
@@ -20,7 +17,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<ConvexAuthNextjsServerProvider>
|
<ConvexAuthNextjsServerProvider>
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={inter.className}>
|
<body className="font-sans">
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="dark"
|
defaultTheme="dark"
|
||||||
|
|||||||
@@ -69,19 +69,19 @@ export default function OnboardingPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await createAnalysis({
|
await createAnalysis({
|
||||||
projectId: resolved.projectId,
|
projectId: resolved.projectId as any,
|
||||||
dataSourceId: resolved.sourceId,
|
dataSourceId: resolved.sourceId as any,
|
||||||
analysis,
|
analysis,
|
||||||
})
|
})
|
||||||
|
|
||||||
await updateDataSourceStatus({
|
await updateDataSourceStatus({
|
||||||
dataSourceId: resolved.sourceId,
|
dataSourceId: resolved.sourceId as any,
|
||||||
analysisStatus: 'completed',
|
analysisStatus: 'completed',
|
||||||
lastAnalyzedAt: Date.now(),
|
lastAnalyzedAt: Date.now(),
|
||||||
})
|
})
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
await updateDataSourceStatus({
|
await updateDataSourceStatus({
|
||||||
dataSourceId: resolved.sourceId,
|
dataSourceId: resolved.sourceId as any,
|
||||||
analysisStatus: 'failed',
|
analysisStatus: 'failed',
|
||||||
lastError: err?.message || 'Failed to save analysis',
|
lastError: err?.message || 'Failed to save analysis',
|
||||||
lastAnalyzedAt: Date.now(),
|
lastAnalyzedAt: Date.now(),
|
||||||
@@ -106,7 +106,7 @@ export default function OnboardingPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await updateDataSourceStatus({
|
await updateDataSourceStatus({
|
||||||
dataSourceId: sourceId,
|
dataSourceId: sourceId as any,
|
||||||
analysisStatus: 'pending',
|
analysisStatus: 'pending',
|
||||||
lastError: undefined,
|
lastError: undefined,
|
||||||
lastAnalyzedAt: undefined,
|
lastAnalyzedAt: undefined,
|
||||||
@@ -116,8 +116,8 @@ export default function OnboardingPage() {
|
|||||||
setPendingProjectId(projectId)
|
setPendingProjectId(projectId)
|
||||||
|
|
||||||
const jobId = await createAnalysisJob({
|
const jobId = await createAnalysisJob({
|
||||||
projectId,
|
projectId: projectId as any,
|
||||||
dataSourceId: sourceId,
|
dataSourceId: sourceId as any,
|
||||||
})
|
})
|
||||||
setPendingJobId(jobId)
|
setPendingJobId(jobId)
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ export default function OnboardingPage() {
|
|||||||
setError(err.message || 'Failed to analyze website')
|
setError(err.message || 'Failed to analyze website')
|
||||||
if (pendingSourceId && !manualFallback) {
|
if (pendingSourceId && !manualFallback) {
|
||||||
await updateDataSourceStatus({
|
await updateDataSourceStatus({
|
||||||
dataSourceId: pendingSourceId,
|
dataSourceId: pendingSourceId as any,
|
||||||
analysisStatus: 'failed',
|
analysisStatus: 'failed',
|
||||||
lastError: err?.message || 'Failed to analyze',
|
lastError: err?.message || 'Failed to analyze',
|
||||||
lastAnalyzedAt: Date.now(),
|
lastAnalyzedAt: Date.now(),
|
||||||
@@ -255,8 +255,8 @@ export default function OnboardingPage() {
|
|||||||
|
|
||||||
if (!resolvedJobId && resolvedProjectId && resolvedSourceId) {
|
if (!resolvedJobId && resolvedProjectId && resolvedSourceId) {
|
||||||
resolvedJobId = await createAnalysisJob({
|
resolvedJobId = await createAnalysisJob({
|
||||||
projectId: resolvedProjectId,
|
projectId: resolvedProjectId as any,
|
||||||
dataSourceId: resolvedSourceId,
|
dataSourceId: resolvedSourceId as any,
|
||||||
})
|
})
|
||||||
setPendingJobId(resolvedJobId)
|
setPendingJobId(resolvedJobId)
|
||||||
}
|
}
|
||||||
@@ -307,8 +307,8 @@ export default function OnboardingPage() {
|
|||||||
analysis: finalAnalysis,
|
analysis: finalAnalysis,
|
||||||
sourceUrl: manualSourceUrl,
|
sourceUrl: manualSourceUrl,
|
||||||
sourceName: finalAnalysis.productName,
|
sourceName: finalAnalysis.productName,
|
||||||
projectId: resolvedProjectId || undefined,
|
projectId: (resolvedProjectId || undefined) as any,
|
||||||
dataSourceId: resolvedSourceId || undefined,
|
dataSourceId: (resolvedSourceId || undefined) as any,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +324,7 @@ export default function OnboardingPage() {
|
|||||||
setError(err.message || 'Failed to analyze')
|
setError(err.message || 'Failed to analyze')
|
||||||
if (pendingSourceId) {
|
if (pendingSourceId) {
|
||||||
await updateDataSourceStatus({
|
await updateDataSourceStatus({
|
||||||
dataSourceId: pendingSourceId,
|
dataSourceId: pendingSourceId as any,
|
||||||
analysisStatus: 'failed',
|
analysisStatus: 'failed',
|
||||||
lastError: err?.message || 'Failed to analyze',
|
lastError: err?.message || 'Failed to analyze',
|
||||||
lastAnalyzedAt: Date.now(),
|
lastAnalyzedAt: Date.now(),
|
||||||
|
|||||||
@@ -96,6 +96,15 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
api.analysisJobs.getById,
|
api.analysisJobs.getById,
|
||||||
pendingJobId ? { jobId: pendingJobId as any } : "skip"
|
pendingJobId ? { jobId: pendingJobId as any } : "skip"
|
||||||
);
|
);
|
||||||
|
const opportunities = useQuery(
|
||||||
|
api.opportunities.listByProject,
|
||||||
|
selectedProjectId
|
||||||
|
? {
|
||||||
|
projectId: selectedProjectId as any,
|
||||||
|
limit: 200,
|
||||||
|
}
|
||||||
|
: "skip"
|
||||||
|
);
|
||||||
|
|
||||||
// Set default selected project
|
// Set default selected project
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -118,6 +127,18 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
const editingProject = projects?.find((project) => project._id === editingProjectId);
|
const editingProject = projects?.find((project) => project._id === editingProjectId);
|
||||||
const canDeleteProject = (projects?.length ?? 0) > 1;
|
const canDeleteProject = (projects?.length ?? 0) > 1;
|
||||||
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || [];
|
const selectedSourceIds = selectedProject?.dorkingConfig?.selectedSourceIds || [];
|
||||||
|
const inboxCount = React.useMemo(() => {
|
||||||
|
if (!opportunities) return 0;
|
||||||
|
const normalized = opportunities as { status?: string }[];
|
||||||
|
return normalized.filter((lead) => {
|
||||||
|
const status = lead.status ?? "new";
|
||||||
|
if (status === "ignored") return false;
|
||||||
|
if (status === "converted") return false;
|
||||||
|
if (status === "archived") return false;
|
||||||
|
if (status === "sent") return false;
|
||||||
|
return true;
|
||||||
|
}).length;
|
||||||
|
}, [opportunities]);
|
||||||
const selectedProjectName = selectedProject?.name || "Select Project";
|
const selectedProjectName = selectedProject?.name || "Select Project";
|
||||||
|
|
||||||
const handleToggle = async (sourceId: string, checked: boolean) => {
|
const handleToggle = async (sourceId: string, checked: boolean) => {
|
||||||
@@ -416,7 +437,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||||||
>
|
>
|
||||||
<Link href="/app/inbox">
|
<Link href="/app/inbox">
|
||||||
<Inbox />
|
<Inbox />
|
||||||
<span>Inbox</span>
|
<span className="truncate">Inbox</span>
|
||||||
|
{inboxCount > 0 && (
|
||||||
|
<div className="relative ml-auto flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold text-primary-foreground group-data-[collapsible=icon]:hidden">
|
||||||
|
<span className="absolute -inset-0.5 animate-ping rounded-full bg-primary/60" />
|
||||||
|
<span className="relative">{inboxCount}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const itemVariants = {
|
|||||||
y: 0,
|
y: 0,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.5,
|
duration: 0.5,
|
||||||
ease: "easeOut",
|
ease: [0.16, 1, 0.3, 1] as [number, number, number, number],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const itemVariants = {
|
|||||||
y: 0,
|
y: 0,
|
||||||
transition: {
|
transition: {
|
||||||
duration: 0.8,
|
duration: 0.8,
|
||||||
ease: [0.22, 1, 0.36, 1], // Custom easing (approx. easeOutQuint/Expo) for a premium feel
|
ease: [0.22, 1, 0.36, 1] as [number, number, number, number], // Custom easing (approx. easeOutQuint/Expo) for a premium feel
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -100,7 +100,12 @@ export const getById = query({
|
|||||||
export const listByProject = query({
|
export const listByProject = query({
|
||||||
args: {
|
args: {
|
||||||
projectId: v.id("projects"),
|
projectId: v.id("projects"),
|
||||||
status: v.optional(v.string()),
|
status: v.optional(v.union(
|
||||||
|
v.literal("pending"),
|
||||||
|
v.literal("running"),
|
||||||
|
v.literal("completed"),
|
||||||
|
v.literal("failed")
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const userId = await getAuthUserId(ctx);
|
const userId = await getAuthUserId(ctx);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const listByProject = query({
|
|||||||
|
|
||||||
if (args.minScore !== undefined) {
|
if (args.minScore !== undefined) {
|
||||||
queryBuilder = queryBuilder.filter((q) =>
|
queryBuilder = queryBuilder.filter((q) =>
|
||||||
q.gte(q.field("relevanceScore"), args.minScore)
|
q.gte(q.field("relevanceScore"), args.minScore as number)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ export const update = mutation({
|
|||||||
export const listByProject = query({
|
export const listByProject = query({
|
||||||
args: {
|
args: {
|
||||||
projectId: v.id("projects"),
|
projectId: v.id("projects"),
|
||||||
status: v.optional(v.string()),
|
status: v.optional(v.union(
|
||||||
|
v.literal("pending"),
|
||||||
|
v.literal("running"),
|
||||||
|
v.literal("completed"),
|
||||||
|
v.literal("failed")
|
||||||
|
)),
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const userId = await getAuthUserId(ctx);
|
const userId = await getAuthUserId(ctx);
|
||||||
|
|||||||
@@ -15,3 +15,4 @@
|
|||||||
|
|
||||||
- Changed global font from Montserrat back to Inter in `app/layout.tsx`
|
- Changed global font from Montserrat back to Inter in `app/layout.tsx`
|
||||||
|
|
||||||
|
- Fixed syntax error in `app/api/opportunities/route.ts` by adding a missing comma in the `searchSchema` definition.
|
||||||
|
|||||||
@@ -180,13 +180,20 @@ Return JSON:
|
|||||||
const analysis = JSON.parse(jsonStr)
|
const analysis = JSON.parse(jsonStr)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: result.url,
|
||||||
title: result.title,
|
title: result.title,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
|
platform: result.source,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
snippet: result.snippet.slice(0, 300),
|
snippet: result.snippet.slice(0, 300),
|
||||||
relevanceScore: analysis.relevanceScore || 0,
|
relevanceScore: analysis.relevanceScore || 0,
|
||||||
painPoints: analysis.painPoints || [],
|
emotionalIntensity: (analysis.painPoints || []).length > 2 ? "high" : (analysis.painPoints || []).length > 0 ? "medium" : "low",
|
||||||
suggestedApproach: analysis.suggestedApproach || ''
|
intent: "looking",
|
||||||
|
matchedKeywords: [],
|
||||||
|
matchedProblems: analysis.painPoints || [],
|
||||||
|
status: "new",
|
||||||
|
suggestedApproach: analysis.suggestedApproach || '',
|
||||||
|
softPitch: false,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Fallback simple analysis
|
// Fallback simple analysis
|
||||||
@@ -195,13 +202,20 @@ Return JSON:
|
|||||||
const relevance = Math.min(overlap / Math.max(product.keywords.length * 0.5, 1), 1)
|
const relevance = Math.min(overlap / Math.max(product.keywords.length * 0.5, 1), 1)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
id: result.url,
|
||||||
title: result.title,
|
title: result.title,
|
||||||
url: result.url,
|
url: result.url,
|
||||||
|
platform: result.source,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
snippet: result.snippet.slice(0, 300),
|
snippet: result.snippet.slice(0, 300),
|
||||||
relevanceScore: relevance,
|
relevanceScore: relevance,
|
||||||
painPoints: ['Related to product domain'],
|
emotionalIntensity: "low",
|
||||||
suggestedApproach: 'Share relevant insights about their problem'
|
intent: "looking",
|
||||||
|
matchedKeywords: product.keywords.slice(0, 5),
|
||||||
|
matchedProblems: ['Related to product domain'],
|
||||||
|
status: "new",
|
||||||
|
suggestedApproach: 'Share relevant insights about their problem',
|
||||||
|
softPitch: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function scrapeWebsite(url: string): Promise<ScrapedContent> {
|
|||||||
let browser
|
let browser
|
||||||
try {
|
try {
|
||||||
browser = await puppeteer.launch({
|
browser = await puppeteer.launch({
|
||||||
headless: 'new',
|
headless: true,
|
||||||
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
args: ['--no-sandbox', '--disable-setuid-sandbox']
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
experimental: {
|
serverExternalPackages: ['puppeteer']
|
||||||
serverComponentsExternalPackages: ['puppeteer']
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
|||||||
755
package-lock.json
generated
755
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "NEXT_DISABLE_TURBOPACK=1 next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
"jose": "^6.1.3",
|
"jose": "^6.1.3",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"mini-svg-data-uri": "^1.4.4",
|
"mini-svg-data-uri": "^1.4.4",
|
||||||
"next": "14.1.0",
|
"next": "16.1.6",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -10,7 +14,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -18,9 +22,20 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": [
|
||||||
}
|
"./*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"target": "ES2017"
|
||||||
"exclude": ["node_modules"]
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user