diff --git a/.gitignore b/.gitignore
index db544e5..c5900c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,9 @@ certificates
# claude code
.claude
+# kiro
+.kiro
+
# typescript
*.tsbuildinfo
next-env.d.ts
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 88531ea..39ee75d 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -26,4 +26,4 @@
".wrangler": true,
".open-next": true
}
-}
\ No newline at end of file
+}
diff --git a/env.example b/env.example
index 94d339e..93eb301 100644
--- a/env.example
+++ b/env.example
@@ -181,3 +181,10 @@ FAL_API_KEY=""
FIREWORKS_API_KEY=""
OPENAI_API_KEY=""
REPLICATE_API_TOKEN=""
+
+# -----------------------------------------------------------------------------
+# Web Content Analyzer (Firecrawl)
+# https://firecrawl.dev/
+# Get API key from https://firecrawl.dev/app
+# -----------------------------------------------------------------------------
+FIRECRAWL_API_KEY=""
diff --git a/messages/en.json b/messages/en.json
index 3baa3e2..8f351a9 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -988,9 +988,53 @@
}
},
"AITextPage": {
- "title": "AI Text",
- "description": "MkSaaS lets you make AI SaaS in days, simply and effortlessly",
- "content": "Working in progress"
+ "title": "AI Text Demo",
+ "description": "Analyze web content with AI to extract key information, features, and insights",
+ "content": "Web Content Analyzer",
+ "subtitle": "Enter a website URL to get AI-powered analysis of its content",
+ "analyzer": {
+ "title": "Web Content Analyzer",
+ "description": "Analyze any website content using AI to extract structured information",
+ "placeholder": "Enter website URL (e.g., https://example.com)",
+ "button": "Analyze Website",
+ "loading": {
+ "scraping": "Scraping website content...",
+ "analyzing": "Analyzing content with AI..."
+ },
+ "results": {
+ "title": "Analysis Results",
+ "newAnalysis": "Analyze Another Website",
+ "sections": {
+ "title": "Title",
+ "description": "Description",
+ "introduction": "Introduction",
+ "features": "Features",
+ "pricing": "Pricing",
+ "useCases": "Use Cases",
+ "screenshot": "Website Screenshot"
+ }
+ },
+ "errors": {
+ "invalidUrl": "Please enter a valid URL starting with http:// or https://",
+ "analysisError": "Failed to analyze website. Please try again.",
+ "networkError": "Network error. Please check your connection and try again.",
+ "insufficientCredits": "Insufficient credits. Please purchase more credits to continue."
+ }
+ },
+ "features": {
+ "scraping": {
+ "title": "Smart Web Scraping",
+ "description": "Advanced web scraping technology extracts clean, structured content from any website"
+ },
+ "analysis": {
+ "title": "AI-Powered Analysis",
+ "description": "Intelligent AI analysis extracts key insights, features, and structured information"
+ },
+ "results": {
+ "title": "Structured Results",
+ "description": "Get organized, easy-to-read results with clear sections and actionable insights"
+ }
+ }
},
"AIImagePage": {
"title": "AI Image",
diff --git a/messages/zh.json b/messages/zh.json
index 6c8dc9d..c0e3157 100644
--- a/messages/zh.json
+++ b/messages/zh.json
@@ -989,8 +989,52 @@
},
"AITextPage": {
"title": "AI 文本",
- "description": "MkSaaS 让您在几天内轻松构建您的 AI SaaS,简单且毫不费力",
- "content": "正在开发中"
+ "description": "使用 AI 分析网页内容,提取关键信息、功能和见解",
+ "content": "网页内容分析器",
+ "subtitle": "输入网站 URL,使用 AI 分析其内容",
+ "analyzer": {
+ "title": "网页内容分析器",
+ "description": "使用 AI 分析任何网站的内容,提取结构化信息",
+ "placeholder": "输入网站 URL(例如:https://example.com)",
+ "button": "分析网站",
+ "loading": {
+ "scraping": "正在抓取网站内容...",
+ "analyzing": "正在使用 AI 分析内容..."
+ },
+ "results": {
+ "title": "分析结果",
+ "newAnalysis": "分析其他网站",
+ "sections": {
+ "title": "标题",
+ "description": "描述",
+ "introduction": "介绍",
+ "features": "功能",
+ "pricing": "定价",
+ "useCases": "使用场景",
+ "screenshot": "网站截图"
+ }
+ },
+ "errors": {
+ "invalidUrl": "请输入以 http:// 或 https:// 开头的有效 URL",
+ "analysisError": "分析网站失败,请重试。",
+ "networkError": "网络错误,请检查您的连接并重试。",
+ "insufficientCredits": "积分不足,请购买更多积分以继续。"
+ }
+ },
+ "features": {
+ "scraping": {
+ "title": "智能网页抓取",
+ "description": "先进的网页抓取技术从任何网站提取干净、结构化的内容"
+ },
+ "analysis": {
+ "title": "AI 驱动分析",
+ "description": "智能 AI 分析提取关键见解、功能和结构化信息"
+ },
+ "results": {
+ "title": "结构化结果",
+ "description": "获得有组织、易于阅读的结果,包含清晰的部分和可操作的见解"
+ }
+ }
},
"AIImagePage": {
"title": "AI 图片",
diff --git a/package.json b/package.json
index 853cfeb..5574c3f 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^4.1.0",
"@marsidev/react-turnstile": "^1.1.0",
+ "@mendable/firecrawl-js": "^1.29.1",
"@next/third-parties": "^15.3.0",
"@openpanel/nextjs": "^1.0.7",
"@orama/orama": "^3.1.4",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index aa72161..0981dd2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
'@marsidev/react-turnstile':
specifier: ^1.1.0
version: 1.1.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+ '@mendable/firecrawl-js':
+ specifier: ^1.29.1
+ version: 1.29.1
'@next/third-parties':
specifier: ^15.3.0
version: 15.3.0(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
@@ -1618,6 +1621,10 @@ packages:
'@mdx-js/mdx@3.1.0':
resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==}
+ '@mendable/firecrawl-js@1.29.1':
+ resolution: {integrity: sha512-w7mXja6hSNL6li7BHgY6LQLnBJ9RIxWkmZ16y2MCOr3w6MlR7k2ZcTxro6vEJrUoshhyoOqhcFCyD1P0ckBuRw==}
+ engines: {node: '>=22.0.0'}
+
'@napi-rs/wasm-runtime@0.2.11':
resolution: {integrity: sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==}
@@ -4218,6 +4225,12 @@ packages:
resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==}
hasBin: true
+ asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+
+ axios@1.10.0:
+ resolution: {integrity: sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==}
+
bail@2.0.2:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
@@ -4367,6 +4380,10 @@ packages:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
+ combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -4504,6 +4521,10 @@ packages:
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
+ delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -4697,6 +4718,10 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
+ es-set-tostringtag@2.1.0:
+ resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
+ engines: {node: '>= 0.4'}
+
esast-util-from-estree@2.0.0:
resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==}
@@ -4807,10 +4832,23 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ follow-redirects@1.15.9:
+ resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
foreground-child@3.3.0:
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
engines: {node: '>=14'}
+ form-data@4.0.4:
+ resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
+ engines: {node: '>= 6'}
+
formatly@0.2.4:
resolution: {integrity: sha512-lIN7GpcvX/l/i24r/L9bnJ0I8Qn01qijWpQpDDvTLL29nKqSaJJu4h20+7VJ6m2CAhQ2/En/GbxDiHCzq/0MyA==}
engines: {node: '>=18.3.0'}
@@ -4965,6 +5003,10 @@ packages:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
+ has-tostringtag@1.0.2:
+ resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
+ engines: {node: '>= 0.4'}
+
hash.js@1.1.7:
resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==}
@@ -5851,6 +5893,9 @@ packages:
resolution: {integrity: sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==}
engines: {node: '>=12.0.0'}
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
pvtsutils@1.3.6:
resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==}
@@ -6356,6 +6401,9 @@ packages:
tw-animate-css@1.2.4:
resolution: {integrity: sha512-yt+HkJB41NAvOffe4NweJU6fLqAlVx/mBX6XmHRp15kq0JxTtOKaIw8pVSWM1Z+n2nXtyi7cW6C9f0WG/F/QAQ==}
+ typescript-event-target@1.1.1:
+ resolution: {integrity: sha512-dFSOFBKV6uwaloBCCUhxlD3Pr/P1a/tJdcmPrTXCHlEFD3faj0mztjcGn6VBAhQ0/Bdy8K3VWrrqwbt/ffsYsg==}
+
typescript@5.8.3:
resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==}
engines: {node: '>=14.17'}
@@ -7489,6 +7537,15 @@ snapshots:
- acorn
- supports-color
+ '@mendable/firecrawl-js@1.29.1':
+ dependencies:
+ axios: 1.10.0
+ typescript-event-target: 1.1.1
+ zod: 3.25.64
+ zod-to-json-schema: 3.24.2(zod@3.25.64)
+ transitivePeerDependencies:
+ - debug
+
'@napi-rs/wasm-runtime@0.2.11':
dependencies:
'@emnapi/core': 1.4.3
@@ -10301,6 +10358,16 @@ snapshots:
astring@1.9.0: {}
+ asynckit@0.4.0: {}
+
+ axios@1.10.0:
+ dependencies:
+ follow-redirects: 1.15.9
+ form-data: 4.0.4
+ proxy-from-env: 1.1.0
+ transitivePeerDependencies:
+ - debug
+
bail@2.0.2: {}
balanced-match@1.0.2: {}
@@ -10465,6 +10532,10 @@ snapshots:
color-string: 1.9.1
optional: true
+ combined-stream@1.0.8:
+ dependencies:
+ delayed-stream: 1.0.0
+
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
@@ -10574,6 +10645,8 @@ snapshots:
defu@6.1.4: {}
+ delayed-stream@1.0.0: {}
+
dequal@2.0.3: {}
detect-libc@2.0.3: {}
@@ -10699,6 +10772,13 @@ snapshots:
dependencies:
es-errors: 1.3.0
+ es-set-tostringtag@2.1.0:
+ dependencies:
+ es-errors: 1.3.0
+ get-intrinsic: 1.2.7
+ has-tostringtag: 1.0.2
+ hasown: 2.0.2
+
esast-util-from-estree@2.0.0:
dependencies:
'@types/estree-jsx': 1.0.5
@@ -10932,11 +11012,21 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
+ follow-redirects@1.15.9: {}
+
foreground-child@3.3.0:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
+ form-data@4.0.4:
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ es-set-tostringtag: 2.1.0
+ hasown: 2.0.2
+ mime-types: 2.1.35
+
formatly@0.2.4:
dependencies:
fd-package-json: 2.0.0
@@ -11129,6 +11219,10 @@ snapshots:
has-symbols@1.1.0: {}
+ has-tostringtag@1.0.2:
+ dependencies:
+ has-symbols: 1.1.0
+
hash.js@1.1.7:
dependencies:
inherits: 2.0.4
@@ -12326,6 +12420,8 @@ snapshots:
'@types/node': 20.19.0
long: 5.3.2
+ proxy-from-env@1.1.0: {}
+
pvtsutils@1.3.6:
dependencies:
tslib: 2.8.1
@@ -12997,6 +13093,8 @@ snapshots:
tw-animate-css@1.2.4: {}
+ typescript-event-target@1.1.1: {}
+
typescript@5.8.3: {}
uncrypto@0.1.3: {}
diff --git a/src/actions/check-web-content-analysis-credits.ts b/src/actions/check-web-content-analysis-credits.ts
new file mode 100644
index 0000000..5d253da
--- /dev/null
+++ b/src/actions/check-web-content-analysis-credits.ts
@@ -0,0 +1,45 @@
+'use server';
+
+import { getWebContentAnalysisCost } from '@/ai/text/utils/web-content-analyzer-config';
+import { getUserCredits, hasEnoughCredits } from '@/credits/credits';
+import { getSession } from '@/lib/server';
+import { createSafeActionClient } from 'next-safe-action';
+
+const actionClient = createSafeActionClient();
+
+/**
+ * Check if user has enough credits for web content analysis
+ */
+export const checkWebContentAnalysisCreditsAction = actionClient.action(
+ async () => {
+ const session = await getSession();
+ if (!session) {
+ console.warn(
+ 'unauthorized request to check web content analysis credits'
+ );
+ return { success: false, error: 'Unauthorized' };
+ }
+
+ try {
+ const requiredCredits = getWebContentAnalysisCost();
+ const currentCredits = await getUserCredits(session.user.id);
+ const hasCredits = await hasEnoughCredits({
+ userId: session.user.id,
+ requiredCredits,
+ });
+
+ return {
+ success: true,
+ hasEnoughCredits: hasCredits,
+ currentCredits,
+ requiredCredits,
+ };
+ } catch (error) {
+ console.error('check web content analysis credits error:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Something went wrong',
+ };
+ }
+ }
+);
diff --git a/src/ai/text/components/analysis-results.tsx b/src/ai/text/components/analysis-results.tsx
new file mode 100644
index 0000000..b533fa3
--- /dev/null
+++ b/src/ai/text/components/analysis-results.tsx
@@ -0,0 +1,303 @@
+'use client';
+
+import type { AnalysisResultsProps } from '@/ai/text/utils/web-content-analyzer';
+import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { Separator } from '@/components/ui/separator';
+import {
+ CalendarIcon,
+ CreditCardIcon,
+ ExternalLinkIcon,
+ ImageIcon,
+ InfoIcon,
+ ListIcon,
+ PlusIcon,
+ RefreshCwIcon,
+ SparklesIcon,
+ TagIcon,
+} from 'lucide-react';
+import Image from 'next/image';
+import { memo, useCallback, useEffect, useRef, useState } from 'react';
+import {
+ ImageOptimization,
+ useLazyLoading,
+ useStableCallback,
+} from '../utils/performance';
+
+// Memoized screenshot component for better performance
+const LazyScreenshot = memo(
+ ({
+ screenshot,
+ title,
+ onLoad,
+ onError,
+ }: {
+ screenshot: string;
+ title: string;
+ onLoad: () => void;
+ onError: () => void;
+ }) => {
+ const [imageRef, isVisible] = useLazyLoading(
+ webContentAnalyzerConfig.performance.lazyLoadingThreshold
+ );
+ const [imageLoading, setImageLoading] = useState(true);
+
+ const handleImageLoad = useCallback(() => {
+ setImageLoading(false);
+ onLoad();
+ }, [onLoad]);
+
+ const handleImageError = useCallback(() => {
+ setImageLoading(false);
+ onError();
+ }, [onError]);
+
+ return (
+
+ {imageLoading && (
+
+
+
+ )}
+
+ {isVisible && (
+
+ )}
+
+
+ );
+ }
+);
+
+LazyScreenshot.displayName = 'LazyScreenshot';
+
+export const AnalysisResults = memo(function AnalysisResults({
+ results,
+ screenshot,
+ onNewAnalysis,
+}: AnalysisResultsProps) {
+ const [imageError, setImageError] = useState(false);
+
+ // Memoized utility functions to prevent re-creation on every render
+ const formatDate = useCallback((dateString: string) => {
+ try {
+ return new Date(dateString).toLocaleString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ } catch {
+ return 'Recently';
+ }
+ }, []);
+
+ const getDomainFromUrl = useCallback((url: string) => {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return url;
+ }
+ }, []);
+
+ const handleImageLoad = useCallback(() => {
+ // Image loaded successfully
+ }, []);
+
+ const handleImageError = useCallback(() => {
+ setImageError(true);
+ }, []);
+
+ // Memoized domain and formatted date to prevent recalculation
+ const domain = getDomainFromUrl(results.url);
+ const formattedDate = formatDate(results.analyzedAt);
+
+ return (
+
+ {/* Header Section */}
+
+
+
+
+
+ {results.title}
+
+
+ {results.description}
+
+
+
+
+
+ Analyzed {formattedDate}
+
+
+
+
+
+
+
+ {/* Info section */}
+
+ {/* Main Content */}
+
+ {/* Introduction Section */}
+
+
+
+
+ Introduction
+
+
+
+
+ {results.introduction}
+
+
+
+
+ {/* Features Section */}
+ {results.features && results.features.length > 0 && (
+
+
+
+
+ Features
+
+
+
+
+ {results.features.map((feature, index) => (
+
+ ))}
+
+
+
+ )}
+
+ {/* Use Cases Section */}
+ {results.useCases && results.useCases.length > 0 && (
+
+
+
+
+ Use Cases
+
+
+
+
+ {results.useCases.map((useCase, index) => (
+
+ {useCase}
+
+ ))}
+
+
+
+ )}
+
+ {/* Pricing Section */}
+ {results.pricing && results.pricing !== 'Not specified' && (
+
+
+
+
+ Pricing
+
+
+
+
+ {results.pricing}
+
+
+
+ )}
+
+
+ {/* Screenshot Sidebar */}
+
+
+
+
+
+ Screenshot
+
+
+
+ {screenshot && !imageError ? (
+
+ ) : (
+
+
+
+
+ {imageError
+ ? 'Failed to load screenshot'
+ : 'No screenshot available'}
+
+
+
+ )}
+
+
+
+
+
+ {/* Action Section */}
+
+
+
+
+ {/*
*/}
+
+
+ );
+});
diff --git a/src/ai/text/components/error-display.tsx b/src/ai/text/components/error-display.tsx
new file mode 100644
index 0000000..489ddd6
--- /dev/null
+++ b/src/ai/text/components/error-display.tsx
@@ -0,0 +1,315 @@
+'use client';
+
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import {
+ AlertCircleIcon,
+ AlertTriangleIcon,
+ ClockIcon,
+ CreditCardIcon,
+ HelpCircleIcon,
+ InfoIcon,
+ RefreshCwIcon,
+ ServerIcon,
+ ShieldIcon,
+ WifiOffIcon,
+} from 'lucide-react';
+import { useState } from 'react';
+import {
+ ErrorSeverity,
+ ErrorType,
+ type WebContentAnalyzerError,
+ getRecoveryActions,
+} from '../utils/error-handling';
+
+interface ErrorDisplayProps {
+ error: WebContentAnalyzerError;
+ onRetry?: () => void;
+ onDismiss?: () => void;
+ className?: string;
+}
+
+// Error icon mapping
+const errorIcons = {
+ [ErrorType.VALIDATION]: AlertCircleIcon,
+ [ErrorType.NETWORK]: WifiOffIcon,
+ [ErrorType.CREDITS]: CreditCardIcon,
+ [ErrorType.SCRAPING]: ServerIcon,
+ [ErrorType.ANALYSIS]: HelpCircleIcon,
+ [ErrorType.TIMEOUT]: ClockIcon,
+ [ErrorType.RATE_LIMIT]: ClockIcon,
+ [ErrorType.AUTHENTICATION]: ShieldIcon,
+ [ErrorType.SERVICE_UNAVAILABLE]: ServerIcon,
+ [ErrorType.UNKNOWN]: AlertTriangleIcon,
+};
+
+// Severity color mapping
+const severityColors = {
+ [ErrorSeverity.LOW]: {
+ border: 'border-blue-200 dark:border-blue-800',
+ bg: 'bg-blue-50 dark:bg-blue-950/20',
+ iconBg: 'bg-blue-100 dark:bg-blue-900/30',
+ iconColor: 'text-blue-600 dark:text-blue-400',
+ titleColor: 'text-blue-800 dark:text-blue-200',
+ textColor: 'text-blue-700 dark:text-blue-300',
+ },
+ [ErrorSeverity.MEDIUM]: {
+ border: 'border-yellow-200 dark:border-yellow-800',
+ bg: 'bg-yellow-50 dark:bg-yellow-950/20',
+ iconBg: 'bg-yellow-100 dark:bg-yellow-900/30',
+ iconColor: 'text-yellow-600 dark:text-yellow-400',
+ titleColor: 'text-yellow-800 dark:text-yellow-200',
+ textColor: 'text-yellow-700 dark:text-yellow-300',
+ },
+ [ErrorSeverity.HIGH]: {
+ border: 'border-red-200 dark:border-red-800',
+ bg: 'bg-red-50 dark:bg-red-950/20',
+ iconBg: 'bg-red-100 dark:bg-red-900/30',
+ iconColor: 'text-red-600 dark:text-red-400',
+ titleColor: 'text-red-800 dark:text-red-200',
+ textColor: 'text-red-700 dark:text-red-300',
+ },
+ [ErrorSeverity.CRITICAL]: {
+ border: 'border-red-200 dark:border-red-800',
+ bg: 'bg-red-50 dark:bg-red-950/20',
+ iconBg: 'bg-red-100 dark:bg-red-900/30',
+ iconColor: 'text-red-600 dark:text-red-400',
+ titleColor: 'text-red-800 dark:text-red-200',
+ textColor: 'text-red-700 dark:text-red-300',
+ },
+};
+
+// Error title mapping
+const errorTitles = {
+ [ErrorType.VALIDATION]: 'Invalid Input',
+ [ErrorType.NETWORK]: 'Connection Error',
+ [ErrorType.CREDITS]: 'Insufficient Credits',
+ [ErrorType.SCRAPING]: 'Unable to Access Website',
+ [ErrorType.ANALYSIS]: 'Analysis Failed',
+ [ErrorType.TIMEOUT]: 'Request Timed Out',
+ [ErrorType.RATE_LIMIT]: 'Rate Limit Exceeded',
+ [ErrorType.AUTHENTICATION]: 'Authentication Required',
+ [ErrorType.SERVICE_UNAVAILABLE]: 'Service Unavailable',
+ [ErrorType.UNKNOWN]: 'Unexpected Error',
+};
+
+export function ErrorDisplay({
+ error,
+ onRetry,
+ onDismiss,
+ className,
+}: ErrorDisplayProps) {
+ const [isRetrying, setIsRetrying] = useState(false);
+
+ const Icon = errorIcons[error.type];
+ const colors = severityColors[error.severity];
+ const title = errorTitles[error.type];
+ const recoveryActions = getRecoveryActions(error);
+
+ const handleRetry = async () => {
+ if (!onRetry) return;
+
+ setIsRetrying(true);
+ try {
+ await onRetry();
+ } finally {
+ setIsRetrying(false);
+ }
+ };
+
+ const handleAction = (action: string) => {
+ switch (action) {
+ case 'retry':
+ handleRetry();
+ break;
+ case 'refresh':
+ window.location.reload();
+ break;
+ case 'check_connection':
+ // Could open a network diagnostic or help page
+ window.open('https://www.google.com', '_blank');
+ break;
+ case 'purchase_credits':
+ // Navigate to credits purchase page
+ window.location.href = '/settings/billing';
+ break;
+ case 'check_balance':
+ // Navigate to dashboard
+ window.location.href = '/dashboard';
+ break;
+ case 'sign_in':
+ // Navigate to sign in
+ window.location.href = '/auth/login';
+ break;
+ case 'check_status':
+ // Could open status page
+ console.log('Check service status');
+ break;
+ case 'report_issue':
+ // Could open support form
+ console.log('Report issue');
+ break;
+ case 'wait_retry':
+ // Wait a bit then retry
+ setTimeout(handleRetry, 5000);
+ break;
+ case 'try_later':
+ onDismiss?.();
+ break;
+ default:
+ handleRetry();
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ {title}
+
+
+ {error.userMessage}
+
+
+ {/* Show technical details in development */}
+ {process.env.NODE_ENV === 'development' && (
+
+
+ Technical Details
+
+
+ Type: {error.type}
+ {'\n'}Severity: {error.severity}
+ {'\n'}Retryable: {error.retryable ? 'Yes' : 'No'}
+ {'\n'}Message: {error.message}
+ {error.originalError &&
+ `\nOriginal: ${error.originalError.message}`}
+
+
+ )}
+
+
+
+
+
+
+
+ {recoveryActions.map((action, index) => (
+
+ ))}
+
+ {onDismiss && (
+
+ )}
+
+
+
+ );
+}
+
+// Simplified error display for inline use
+export function InlineErrorDisplay({
+ error,
+ onRetry,
+ className,
+}: {
+ error: WebContentAnalyzerError;
+ onRetry?: () => void;
+ className?: string;
+}) {
+ const [isRetrying, setIsRetrying] = useState(false);
+ const colors = severityColors[error.severity];
+
+ const handleRetry = async () => {
+ if (!onRetry) return;
+
+ setIsRetrying(true);
+ try {
+ await onRetry();
+ } finally {
+ setIsRetrying(false);
+ }
+ };
+
+ return (
+
+
+
+ {error.userMessage}
+
+ {error.retryable && onRetry && (
+
+ )}
+
+ );
+}
diff --git a/src/ai/text/components/index.ts b/src/ai/text/components/index.ts
new file mode 100644
index 0000000..d0cea3d
--- /dev/null
+++ b/src/ai/text/components/index.ts
@@ -0,0 +1,5 @@
+export { AnalysisResults } from './analysis-results';
+export { ConsumeCreditCard } from './consume-credit-card';
+export { LoadingStates } from './loading-states';
+export { UrlInputForm } from './url-input-form';
+export { WebContentAnalyzer } from './web-content-analyzer';
diff --git a/src/ai/text/components/loading-states.tsx b/src/ai/text/components/loading-states.tsx
new file mode 100644
index 0000000..0af349c
--- /dev/null
+++ b/src/ai/text/components/loading-states.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+import type { LoadingStatesProps } from '@/ai/text/utils/web-content-analyzer';
+import { Progress } from '@/components/ui/progress';
+import { BotIcon, Globe2Icon, Loader2Icon, SearchIcon } from 'lucide-react';
+import { memo, useEffect, useMemo, useState } from 'react';
+
+export const LoadingStates = memo(function LoadingStates({
+ stage,
+ url,
+}: LoadingStatesProps) {
+ const [progress, setProgress] = useState(0);
+
+ // Simulate progress animation
+ useEffect(() => {
+ const interval = setInterval(() => {
+ setProgress((prev) => {
+ if (stage === 'scraping') {
+ // Scraping progress: 0-60%
+ return prev < 60 ? prev + 2 : 60;
+ }
+ if (stage === 'analyzing') {
+ // Analyzing progress: 60-100%
+ return prev < 100 ? prev + 1.5 : 100;
+ }
+ return prev;
+ });
+ }, 100);
+
+ return () => clearInterval(interval);
+ }, [stage]);
+
+ // Reset progress when stage changes
+ useEffect(() => {
+ if (stage === 'scraping') {
+ setProgress(0);
+ } else if (stage === 'analyzing') {
+ setProgress(60);
+ }
+ }, [stage]);
+
+ // Memoize stage configuration to prevent unnecessary recalculations
+ const config = useMemo(() => {
+ const hostname = url
+ ? (() => {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return 'the webpage';
+ }
+ })()
+ : 'the webpage';
+
+ switch (stage) {
+ case 'scraping':
+ return {
+ icon: Globe2Icon,
+ title: 'Scraping URL...',
+ description: `Extracting content from ${hostname}`,
+ color: 'text-blue-600 dark:text-blue-400',
+ bgColor: 'bg-blue-50 dark:bg-blue-950/20',
+ borderColor: 'border-blue-200 dark:border-blue-800',
+ };
+ case 'analyzing':
+ return {
+ icon: BotIcon,
+ title: 'Analyzing content...',
+ description: 'AI is processing and structuring the webpage content',
+ color: 'text-purple-600 dark:text-purple-400',
+ bgColor: 'bg-purple-50 dark:bg-purple-950/20',
+ borderColor: 'border-purple-200 dark:border-purple-800',
+ };
+ default:
+ return {
+ icon: Loader2Icon,
+ title: 'Processing...',
+ description: 'Please wait while we process your request',
+ color: 'text-gray-600 dark:text-gray-400',
+ bgColor: 'bg-gray-50 dark:bg-gray-950/20',
+ borderColor: 'border-gray-200 dark:border-gray-800',
+ };
+ }
+ }, [stage, url]);
+
+ const IconComponent = config.icon;
+
+ return (
+
+
+
+
+
+
+
+
+ {config.title}
+
+
+ {Math.round(progress)}%
+
+
+
+
+ {config.description}
+
+
+
+
+
+
+ = 60
+ ? config.color
+ : 'text-muted-foreground'
+ }
+ >
+ Scraping content
+
+ = 60
+ ? config.color
+ : 'text-muted-foreground'
+ }
+ >
+ AI analysis
+
+
+
+
+
+
+
+ );
+});
diff --git a/src/ai/text/components/url-input-form.tsx b/src/ai/text/components/url-input-form.tsx
new file mode 100644
index 0000000..e5c9090
--- /dev/null
+++ b/src/ai/text/components/url-input-form.tsx
@@ -0,0 +1,265 @@
+'use client';
+
+import { checkWebContentAnalysisCreditsAction } from '@/actions/check-web-content-analysis-credits';
+import {
+ type UrlInputFormProps,
+ urlSchema,
+} from '@/ai/text/utils/web-content-analyzer';
+import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
+import { LoginWrapper } from '@/components/auth/login-wrapper';
+import { Button } from '@/components/ui/button';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { useLocalePathname } from '@/i18n/navigation';
+import { authClient } from '@/lib/auth-client';
+import { zodResolver } from '@hookform/resolvers/zod';
+import {
+ AlertCircleIcon,
+ CoinsIcon,
+ LinkIcon,
+ Loader2Icon,
+ LogInIcon,
+ SparklesIcon,
+} from 'lucide-react';
+import { useAction } from 'next-safe-action/hooks';
+import { useEffect, useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { toast } from 'sonner';
+import { z } from 'zod';
+import { useDebounce } from '../utils/performance';
+
+// Form schema for URL input
+const urlFormSchema = z.object({
+ url: urlSchema,
+});
+
+type UrlFormData = z.infer;
+
+export function UrlInputForm({
+ onSubmit,
+ isLoading,
+ disabled = false,
+}: UrlInputFormProps) {
+ const [creditInfo, setCreditInfo] = useState<{
+ hasEnoughCredits: boolean;
+ currentCredits: number;
+ requiredCredits: number;
+ } | null>(null);
+ const [mounted, setMounted] = useState(false);
+
+ // Get authentication status and current path for callback
+ const { data: session, isPending: isAuthLoading } = authClient.useSession();
+ const isAuthenticated = !!session?.user;
+ const currentPath = useLocalePathname();
+
+ // Prevent hydration mismatch by only rendering auth-dependent content after mount
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ const form = useForm({
+ resolver: zodResolver(urlFormSchema),
+ defaultValues: {
+ url: '',
+ },
+ mode: 'onSubmit', // Only validate on submit to avoid premature errors
+ });
+
+ // Watch the URL field for debouncing
+ const urlValue = form.watch('url');
+ const debouncedUrl = useDebounce(
+ urlValue,
+ webContentAnalyzerConfig.performance.urlInputDebounceMs
+ );
+
+ const { execute: checkCredits, isExecuting: isCheckingCredits } = useAction(
+ checkWebContentAnalysisCreditsAction,
+ {
+ onSuccess: (result) => {
+ if (result.data?.success) {
+ setCreditInfo({
+ hasEnoughCredits: result.data.hasEnoughCredits ?? false,
+ currentCredits: result.data.currentCredits ?? 0,
+ requiredCredits: result.data.requiredCredits ?? 0,
+ });
+ } else {
+ // Only show error toast if it's not an auth error
+ if (result.data?.error !== 'Unauthorized') {
+ setTimeout(() => {
+ toast.error(result.data?.error || 'Failed to check credits');
+ }, 0);
+ }
+ }
+ },
+ onError: (error) => {
+ console.error('Credit check error:', error);
+ // Only show error toast for non-auth errors
+ setTimeout(() => {
+ toast.error('Failed to check credits');
+ }, 0);
+ },
+ }
+ );
+
+ // Check credits only when user is authenticated
+ useEffect(() => {
+ if (isAuthenticated && !isAuthLoading) {
+ checkCredits();
+ }
+ }, [isAuthenticated, isAuthLoading, checkCredits]);
+
+ // Debounced URL validation effect
+ useEffect(() => {
+ if (debouncedUrl && debouncedUrl !== urlValue) {
+ // Trigger validation when debounced value changes
+ form.trigger('url');
+ }
+ }, [debouncedUrl, urlValue, form]);
+
+ const handleSubmit = (data: UrlFormData) => {
+ // For authenticated users, check credits before submitting
+ if (creditInfo && !creditInfo.hasEnoughCredits) {
+ // Defer toast to avoid flushSync during render
+ setTimeout(() => {
+ toast.error(
+ `Insufficient credits. You need ${creditInfo.requiredCredits} credits but only have ${creditInfo.currentCredits}.`
+ );
+ }, 0);
+ return;
+ }
+ onSubmit(data.url);
+ };
+
+ const handleFormSubmit = form.handleSubmit(handleSubmit);
+
+ const isInsufficientCredits = creditInfo && !creditInfo.hasEnoughCredits;
+ const isFormDisabled = isLoading || disabled || !!isInsufficientCredits;
+
+ return (
+ <>
+
+ >
+ );
+}
diff --git a/src/ai/text/components/web-content-analyzer.tsx b/src/ai/text/components/web-content-analyzer.tsx
new file mode 100644
index 0000000..1c32944
--- /dev/null
+++ b/src/ai/text/components/web-content-analyzer.tsx
@@ -0,0 +1,456 @@
+'use client';
+
+import type {
+ AnalysisState,
+ AnalyzeContentResponse,
+ WebContentAnalyzerProps,
+} from '@/ai/text/utils/web-content-analyzer';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { Component, useCallback, useReducer, useState } from 'react';
+import { toast } from 'sonner';
+import {
+ ErrorSeverity,
+ ErrorType,
+ WebContentAnalyzerError,
+ classifyError,
+ logError,
+ withRetry,
+} from '../utils/error-handling';
+import { AnalysisResults as AnalysisResultsComponent } from './analysis-results';
+import { LoadingStates } from './loading-states';
+import { UrlInputForm } from './url-input-form';
+
+// Action types for state reducer
+type AnalysisAction =
+ | { type: 'START_ANALYSIS'; payload: { url: string } }
+ | { type: 'SET_LOADING_STAGE'; payload: { stage: 'scraping' | 'analyzing' } }
+ | {
+ type: 'SET_RESULTS';
+ payload: { results: AnalysisState['results']; screenshot?: string };
+ }
+ | { type: 'SET_ERROR'; payload: { error: string } }
+ | { type: 'RESET' };
+
+// State reducer for better state management and performance
+function analysisReducer(
+ state: AnalysisState,
+ action: AnalysisAction
+): AnalysisState {
+ switch (action.type) {
+ case 'START_ANALYSIS':
+ return {
+ ...state,
+ url: action.payload.url,
+ isLoading: true,
+ loadingStage: 'scraping',
+ results: null,
+ error: null,
+ screenshot: undefined,
+ };
+ case 'SET_LOADING_STAGE':
+ return {
+ ...state,
+ loadingStage: action.payload.stage,
+ };
+ case 'SET_RESULTS':
+ return {
+ ...state,
+ isLoading: false,
+ loadingStage: null,
+ results: action.payload.results,
+ screenshot: action.payload.screenshot,
+ error: null,
+ };
+ case 'SET_ERROR':
+ return {
+ ...state,
+ isLoading: false,
+ loadingStage: null,
+ error: action.payload.error,
+ };
+ case 'RESET':
+ return {
+ url: '',
+ isLoading: false,
+ loadingStage: null,
+ results: null,
+ error: null,
+ screenshot: undefined,
+ };
+ default:
+ return state;
+ }
+}
+
+// Initial state
+const initialState: AnalysisState = {
+ url: '',
+ isLoading: false,
+ loadingStage: null,
+ results: null,
+ error: null,
+ screenshot: undefined,
+};
+
+// Error boundary component for handling component errors
+class ErrorBoundary extends Component<
+ {
+ children: React.ReactNode;
+ onError: (error: Error) => void;
+ },
+ { hasError: boolean }
+> {
+ constructor(props: {
+ children: React.ReactNode;
+ onError: (error: Error) => void;
+ }) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError(_: Error) {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
+ console.error(
+ 'WebContentAnalyzer Error Boundary caught an error:',
+ error,
+ errorInfo
+ );
+ this.props.onError(error);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+
+
+
+ Component Error
+
+
+ An unexpected error occurred. Please refresh the page and try
+ again.
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
+ // Use reducer for better state management and performance
+ const [state, dispatch] = useReducer(analysisReducer, initialState);
+
+ // Enhanced error state
+ const [analyzedError, setAnalyzedError] =
+ useState(null);
+
+ // Handle analysis submission with enhanced error handling
+ const handleAnalyzeUrl = useCallback(async (url: string) => {
+ // Reset state and start analysis
+ dispatch({ type: 'START_ANALYSIS', payload: { url } });
+ setAnalyzedError(null);
+
+ try {
+ // Use retry mechanism for the API call
+ const result = await withRetry(async () => {
+ const response = await fetch('/api/analyze-content', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ url }),
+ });
+
+ const data: AnalyzeContentResponse = await response.json();
+
+ // Handle HTTP errors
+ if (!response.ok) {
+ // Create specific error based on status code
+ let errorType = ErrorType.UNKNOWN;
+ let severity = ErrorSeverity.MEDIUM;
+ let retryable = true;
+
+ switch (response.status) {
+ case 400:
+ errorType = ErrorType.VALIDATION;
+ retryable = false;
+ break;
+ case 401:
+ errorType = ErrorType.AUTHENTICATION;
+ severity = ErrorSeverity.HIGH;
+ retryable = false;
+ break;
+ case 402:
+ errorType = ErrorType.CREDITS;
+ severity = ErrorSeverity.HIGH;
+ retryable = false;
+ break;
+ case 408:
+ errorType = ErrorType.TIMEOUT;
+ break;
+ case 422:
+ errorType = ErrorType.SCRAPING;
+ break;
+ case 429:
+ errorType = ErrorType.RATE_LIMIT;
+ break;
+ case 503:
+ errorType = ErrorType.SERVICE_UNAVAILABLE;
+ severity = ErrorSeverity.HIGH;
+ break;
+ default:
+ errorType = ErrorType.NETWORK;
+ }
+
+ throw new WebContentAnalyzerError(
+ errorType,
+ data.error || `HTTP ${response.status}: ${response.statusText}`,
+ data.error || 'Failed to analyze website. Please try again.',
+ severity,
+ retryable
+ );
+ }
+
+ if (!data.success || !data.data) {
+ throw new WebContentAnalyzerError(
+ ErrorType.ANALYSIS,
+ data.error || 'Analysis failed',
+ data.error ||
+ 'Failed to analyze website content. Please try again.',
+ ErrorSeverity.MEDIUM,
+ true
+ );
+ }
+
+ return data;
+ });
+
+ // Update state to analyzing stage
+ dispatch({ type: 'SET_LOADING_STAGE', payload: { stage: 'analyzing' } });
+
+ // Simulate a brief delay for analyzing stage to show progress
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ // Set results and complete analysis
+ dispatch({
+ type: 'SET_RESULTS',
+ payload: {
+ results: result.data!.analysis,
+ screenshot: result.data!.screenshot,
+ },
+ });
+
+ // Show success toast - defer to avoid flushSync during render
+ setTimeout(() => {
+ toast.success('Website analysis completed successfully!', {
+ description: `Analyzed ${new URL(url).hostname}`,
+ });
+ }, 0);
+ } catch (error) {
+ // Classify the error
+ const analyzedError =
+ error instanceof WebContentAnalyzerError ? error : classifyError(error);
+
+ // Log the error
+ logError(analyzedError, { url, component: 'WebContentAnalyzer' });
+
+ // Update state with error
+ dispatch({
+ type: 'SET_ERROR',
+ payload: { error: analyzedError.userMessage },
+ });
+
+ // Set the analyzed error for the ErrorDisplay component
+ setAnalyzedError(analyzedError);
+
+ // Show error toast with appropriate severity - defer to avoid flushSync during render
+ const toastOptions = {
+ description: analyzedError.userMessage,
+ };
+
+ setTimeout(() => {
+ switch (analyzedError.severity) {
+ case ErrorSeverity.CRITICAL:
+ case ErrorSeverity.HIGH:
+ toast.error('Analysis Failed', toastOptions);
+ break;
+ case ErrorSeverity.MEDIUM:
+ toast.warning('Analysis Failed', toastOptions);
+ break;
+ case ErrorSeverity.LOW:
+ toast.info('Analysis Issue', toastOptions);
+ break;
+ }
+ }, 0);
+ }
+ }, []);
+
+ // Handle starting a new analysis
+ const handleNewAnalysis = useCallback(() => {
+ dispatch({ type: 'RESET' });
+ setAnalyzedError(null);
+ }, []);
+
+ // Handle component errors
+ const handleError = useCallback((error: Error) => {
+ console.error('WebContentAnalyzer component error:', error);
+
+ dispatch({
+ type: 'SET_ERROR',
+ payload: {
+ error:
+ 'An unexpected error occurred. Please refresh the page and try again.',
+ },
+ });
+
+ // Defer toast to avoid flushSync during render
+ setTimeout(() => {
+ toast.error('Component error', {
+ description: 'An unexpected error occurred. Please refresh the page.',
+ });
+ }, 0);
+ }, []);
+
+ return (
+
+
+ {/* Main Content Area */}
+
+ {/* URL Input Form - Always visible */}
+ {!state.results && (
+
+ )}
+
+ {/* Loading States */}
+ {state.isLoading && state.loadingStage && (
+
+ )}
+
+ {/* Error State */}
+ {state.error && !state.isLoading && (
+
+
+
+
+
+
+ Analysis Failed
+
+
+ {state.error}
+
+
+
+
+
+
+ )}
+
+ {/* Analysis Results */}
+ {state.results && !state.isLoading && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/ai/text/utils/error-handling.ts b/src/ai/text/utils/error-handling.ts
new file mode 100644
index 0000000..8896d5e
--- /dev/null
+++ b/src/ai/text/utils/error-handling.ts
@@ -0,0 +1,358 @@
+/**
+ * Error handling utilities for web content analyzer
+ */
+
+// Import configuration for performance settings
+import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
+
+// Error types for different failure scenarios
+export enum ErrorType {
+ VALIDATION = 'validation',
+ NETWORK = 'network',
+ CREDITS = 'credits',
+ SCRAPING = 'scraping',
+ ANALYSIS = 'analysis',
+ TIMEOUT = 'timeout',
+ RATE_LIMIT = 'rate_limit',
+ AUTHENTICATION = 'authentication',
+ SERVICE_UNAVAILABLE = 'service_unavailable',
+ UNKNOWN = 'unknown',
+}
+
+// Error severity levels
+export enum ErrorSeverity {
+ LOW = 'low',
+ MEDIUM = 'medium',
+ HIGH = 'high',
+ CRITICAL = 'critical',
+}
+
+// Custom error class for web content analyzer
+export class WebContentAnalyzerError extends Error {
+ public readonly type: ErrorType;
+ public readonly severity: ErrorSeverity;
+ public readonly retryable: boolean;
+ public readonly userMessage: string;
+ public readonly originalError?: Error;
+
+ constructor(
+ type: ErrorType,
+ message: string,
+ userMessage: string,
+ severity: ErrorSeverity = ErrorSeverity.MEDIUM,
+ retryable = false,
+ originalError?: Error
+ ) {
+ super(message);
+ this.name = 'WebContentAnalyzerError';
+ this.type = type;
+ this.severity = severity;
+ this.retryable = retryable;
+ this.userMessage = userMessage;
+ this.originalError = originalError;
+ }
+}
+
+// Error classification function
+export function classifyError(error: unknown): WebContentAnalyzerError {
+ if (error instanceof WebContentAnalyzerError) {
+ return error;
+ }
+
+ if (error instanceof Error) {
+ const message = error.message.toLowerCase();
+
+ // Network errors
+ if (
+ message.includes('network') ||
+ message.includes('fetch') ||
+ message.includes('connection') ||
+ message.includes('econnreset') ||
+ message.includes('enotfound')
+ ) {
+ return new WebContentAnalyzerError(
+ ErrorType.NETWORK,
+ error.message,
+ 'Network connection failed. Please check your internet connection and try again.',
+ ErrorSeverity.MEDIUM,
+ true,
+ error
+ );
+ }
+
+ // Timeout errors
+ if (
+ message.includes('timeout') ||
+ message.includes('timed out') ||
+ message.includes('aborted')
+ ) {
+ return new WebContentAnalyzerError(
+ ErrorType.TIMEOUT,
+ error.message,
+ 'Request timed out. Please try again with a simpler webpage.',
+ ErrorSeverity.MEDIUM,
+ true,
+ error
+ );
+ }
+
+ // Credit errors
+ if (
+ message.includes('credit') ||
+ message.includes('insufficient') ||
+ message.includes('balance')
+ ) {
+ return new WebContentAnalyzerError(
+ ErrorType.CREDITS,
+ error.message,
+ 'Insufficient credits to perform analysis. Please purchase more credits.',
+ ErrorSeverity.HIGH,
+ false,
+ error
+ );
+ }
+
+ // Scraping errors
+ if (
+ message.includes('scrape') ||
+ message.includes('firecrawl') ||
+ message.includes('webpage') ||
+ message.includes('content not found')
+ ) {
+ return new WebContentAnalyzerError(
+ ErrorType.SCRAPING,
+ error.message,
+ 'Unable to access the webpage. Please check the URL and try again.',
+ ErrorSeverity.MEDIUM,
+ true,
+ error
+ );
+ }
+
+ // Analysis errors
+ if (
+ message.includes('analyze') ||
+ message.includes('openai') ||
+ message.includes('ai') ||
+ message.includes('model')
+ ) {
+ return new WebContentAnalyzerError(
+ ErrorType.ANALYSIS,
+ error.message,
+ 'Failed to analyze webpage content. Please try again.',
+ ErrorSeverity.MEDIUM,
+ true,
+ error
+ );
+ }
+
+ // Rate limit errors
+ if (
+ message.includes('rate limit') ||
+ message.includes('too many requests') ||
+ message.includes('quota')
+ ) {
+ return new WebContentAnalyzerError(
+ ErrorType.RATE_LIMIT,
+ error.message,
+ 'Too many requests. Please wait a moment and try again.',
+ ErrorSeverity.MEDIUM,
+ true,
+ error
+ );
+ }
+
+ // Authentication errors
+ if (
+ message.includes('unauthorized') ||
+ message.includes('authentication') ||
+ message.includes('token')
+ ) {
+ return new WebContentAnalyzerError(
+ ErrorType.AUTHENTICATION,
+ error.message,
+ 'Authentication failed. Please refresh the page and try again.',
+ ErrorSeverity.HIGH,
+ false,
+ error
+ );
+ }
+
+ // Service unavailable errors
+ if (
+ message.includes('service unavailable') ||
+ message.includes('503') ||
+ message.includes('502') ||
+ message.includes('500')
+ ) {
+ return new WebContentAnalyzerError(
+ ErrorType.SERVICE_UNAVAILABLE,
+ error.message,
+ 'Service is temporarily unavailable. Please try again later.',
+ ErrorSeverity.HIGH,
+ true,
+ error
+ );
+ }
+ }
+
+ // Unknown error
+ return new WebContentAnalyzerError(
+ ErrorType.UNKNOWN,
+ error instanceof Error ? error.message : 'Unknown error occurred',
+ 'An unexpected error occurred. Please try again.',
+ ErrorSeverity.MEDIUM,
+ true,
+ error instanceof Error ? error : undefined
+ );
+}
+
+// Retry configuration
+export interface RetryConfig {
+ maxAttempts: number;
+ baseDelay: number;
+ maxDelay: number;
+ backoffMultiplier: number;
+}
+
+export const defaultRetryConfig: RetryConfig = {
+ maxAttempts: webContentAnalyzerConfig.performance.maxRetryAttempts,
+ baseDelay: webContentAnalyzerConfig.performance.retryDelayMs,
+ maxDelay: 10000, // 10 seconds
+ backoffMultiplier: 2,
+};
+
+// Retry utility with exponential backoff
+export async function withRetry(
+ operation: () => Promise,
+ config: RetryConfig = defaultRetryConfig
+): Promise {
+ let lastError: WebContentAnalyzerError;
+
+ for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
+ try {
+ return await operation();
+ } catch (error) {
+ lastError = classifyError(error);
+
+ // Don't retry if error is not retryable or this is the last attempt
+ if (!lastError.retryable || attempt === config.maxAttempts) {
+ throw lastError;
+ }
+
+ // Calculate delay with exponential backoff
+ const delay = Math.min(
+ config.baseDelay * config.backoffMultiplier ** (attempt - 1),
+ config.maxDelay
+ );
+
+ console.warn(
+ `Attempt ${attempt} failed, retrying in ${delay}ms:`,
+ lastError.message
+ );
+
+ // Wait before retrying
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ }
+ }
+
+ throw lastError!;
+}
+
+// Error recovery suggestions
+export function getRecoveryActions(error: WebContentAnalyzerError): Array<{
+ label: string;
+ action: string;
+ primary?: boolean;
+}> {
+ switch (error.type) {
+ case ErrorType.NETWORK:
+ return [
+ { label: 'Try Again', action: 'retry', primary: true },
+ { label: 'Check Connection', action: 'check_connection' },
+ ];
+
+ case ErrorType.TIMEOUT:
+ return [
+ { label: 'Try Again', action: 'retry', primary: true },
+ { label: 'Try Simpler URL', action: 'simplify_url' },
+ ];
+
+ case ErrorType.CREDITS:
+ return [
+ {
+ label: 'Purchase Credits',
+ action: 'purchase_credits',
+ primary: true,
+ },
+ { label: 'Check Balance', action: 'check_balance' },
+ ];
+
+ case ErrorType.SCRAPING:
+ return [
+ { label: 'Try Again', action: 'retry', primary: true },
+ { label: 'Check URL', action: 'check_url' },
+ ];
+
+ case ErrorType.ANALYSIS:
+ return [
+ { label: 'Try Again', action: 'retry', primary: true },
+ { label: 'Report Issue', action: 'report_issue' },
+ ];
+
+ case ErrorType.RATE_LIMIT:
+ return [{ label: 'Wait and Retry', action: 'wait_retry', primary: true }];
+
+ case ErrorType.AUTHENTICATION:
+ return [
+ { label: 'Refresh Page', action: 'refresh', primary: true },
+ { label: 'Sign In Again', action: 'sign_in' },
+ ];
+
+ case ErrorType.SERVICE_UNAVAILABLE:
+ return [
+ { label: 'Try Later', action: 'try_later', primary: true },
+ { label: 'Check Status', action: 'check_status' },
+ ];
+
+ default:
+ return [
+ { label: 'Try Again', action: 'retry', primary: true },
+ { label: 'Refresh Page', action: 'refresh' },
+ ];
+ }
+}
+
+// Error logging utility
+export function logError(
+ error: WebContentAnalyzerError,
+ context?: Record
+) {
+ const logData = {
+ type: error.type,
+ severity: error.severity,
+ message: error.message,
+ userMessage: error.userMessage,
+ retryable: error.retryable,
+ context,
+ stack: error.stack,
+ originalError: error.originalError?.message,
+ timestamp: new Date().toISOString(),
+ };
+
+ // Log based on severity
+ switch (error.severity) {
+ case ErrorSeverity.CRITICAL:
+ console.error('CRITICAL WebContentAnalyzer Error:', logData);
+ break;
+ case ErrorSeverity.HIGH:
+ console.error('HIGH WebContentAnalyzer Error:', logData);
+ break;
+ case ErrorSeverity.MEDIUM:
+ console.warn('MEDIUM WebContentAnalyzer Error:', logData);
+ break;
+ case ErrorSeverity.LOW:
+ console.info('LOW WebContentAnalyzer Error:', logData);
+ break;
+ }
+}
diff --git a/src/ai/text/utils/performance.ts b/src/ai/text/utils/performance.ts
new file mode 100644
index 0000000..e629621
--- /dev/null
+++ b/src/ai/text/utils/performance.ts
@@ -0,0 +1,251 @@
+/**
+ * Performance optimization utilities for the web content analyzer
+ */
+
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+/**
+ * Custom hook for debouncing values
+ * @param value - The value to debounce
+ * @param delay - Delay in milliseconds
+ * @returns The debounced value
+ */
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+
+ return debouncedValue;
+}
+
+/**
+ * Custom hook for throttling function calls
+ * @param callback - The function to throttle
+ * @param delay - Delay in milliseconds
+ * @returns The throttled function
+ */
+export function useThrottle any>(
+ callback: T,
+ delay: number
+): T {
+ const lastRun = useRef(Date.now());
+
+ return useCallback(
+ ((...args) => {
+ if (Date.now() - lastRun.current >= delay) {
+ callback(...args);
+ lastRun.current = Date.now();
+ }
+ }) as T,
+ [callback, delay]
+ );
+}
+
+/**
+ * Custom hook for lazy loading with Intersection Observer
+ * @param threshold - Intersection threshold (0-1)
+ * @param rootMargin - Root margin for the observer
+ * @returns [ref, isIntersecting] tuple
+ */
+export function useLazyLoading(
+ threshold = 0.1,
+ rootMargin = '0px'
+): [React.RefObject, boolean] {
+ const [isIntersecting, setIsIntersecting] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setIsIntersecting(true);
+ observer.disconnect();
+ }
+ },
+ { threshold, rootMargin }
+ );
+
+ if (ref.current) {
+ observer.observe(ref.current);
+ }
+
+ return () => observer.disconnect();
+ }, [threshold, rootMargin]);
+
+ return [ref, isIntersecting];
+}
+
+/**
+ * Custom hook for memoizing expensive calculations
+ * @param factory - Function that returns the value to memoize
+ * @param deps - Dependencies array
+ * @returns The memoized value
+ */
+export function useMemoizedValue(
+ factory: () => T,
+ deps: React.DependencyList
+): T {
+ const [value, setValue] = useState(factory);
+ const depsRef = useRef(deps);
+
+ useEffect(() => {
+ // Check if dependencies have changed
+ const hasChanged = deps.some(
+ (dep, index) => dep !== depsRef.current[index]
+ );
+
+ if (hasChanged) {
+ setValue(factory());
+ depsRef.current = deps;
+ }
+ }, deps);
+
+ return value;
+}
+
+/**
+ * Utility function to truncate text at word boundaries
+ * @param text - Text to truncate
+ * @param maxLength - Maximum length
+ * @param suffix - Suffix to add when truncated
+ * @returns Truncated text
+ */
+export function truncateAtWordBoundary(
+ text: string,
+ maxLength: number,
+ suffix = '...'
+): string {
+ if (text.length <= maxLength) {
+ return text;
+ }
+
+ const truncated = text.substring(0, maxLength - suffix.length);
+ const lastSpace = truncated.lastIndexOf(' ');
+
+ if (lastSpace > maxLength * 0.8) {
+ return truncated.substring(0, lastSpace) + suffix;
+ }
+
+ return truncated + suffix;
+}
+
+/**
+ * Utility function to create a stable callback reference
+ * @param callback - The callback function
+ * @param deps - Dependencies array
+ * @returns Stable callback reference
+ */
+export function useStableCallback any>(
+ callback: T,
+ deps: React.DependencyList
+): T {
+ const callbackRef = useRef(callback);
+
+ useEffect(() => {
+ callbackRef.current = callback;
+ }, deps);
+
+ return useCallback(((...args) => callbackRef.current(...args)) as T, []);
+}
+
+/**
+ * Performance monitoring utility
+ */
+const timers = new Map();
+
+export const PerformanceMonitor = {
+ start(label: string): void {
+ timers.set(label, performance.now());
+ },
+
+ end(label: string): number {
+ const startTime = timers.get(label);
+ if (!startTime) {
+ console.warn(`Performance timer '${label}' was not started`);
+ return 0;
+ }
+
+ const duration = performance.now() - startTime;
+ timers.delete(label);
+
+ if (process.env.NODE_ENV === 'development') {
+ console.log(`⏱️ ${label}: ${duration.toFixed(2)}ms`);
+ }
+
+ return duration;
+ },
+
+ measure(label: string, fn: () => T): T {
+ PerformanceMonitor.start(label);
+ try {
+ return fn();
+ } finally {
+ PerformanceMonitor.end(label);
+ }
+ },
+
+ async measureAsync(label: string, fn: () => Promise): Promise {
+ PerformanceMonitor.start(label);
+ try {
+ return await fn();
+ } finally {
+ PerformanceMonitor.end(label);
+ }
+ },
+};
+
+/**
+ * Image optimization utilities
+ */
+export const ImageOptimization = {
+ /**
+ * Create optimized image loading attributes
+ */
+ getOptimizedImageProps: (src: string, alt: string, priority = false) => ({
+ src,
+ alt,
+ loading: priority ? 'eager' : ('lazy' as const),
+ decoding: 'async' as const,
+ style: { contentVisibility: 'auto' } as React.CSSProperties,
+ }),
+
+ /**
+ * Generate responsive image sizes
+ */
+ getResponsiveSizes: (breakpoints: Record) => {
+ return Object.entries(breakpoints)
+ .map(([breakpoint, size]) => `(max-width: ${breakpoint}) ${size}`)
+ .join(', ');
+ },
+};
+
+/**
+ * Content optimization utilities
+ */
+export const ContentOptimization = {
+ /**
+ * Optimize content for display by removing excessive whitespace
+ */
+ optimizeContent: (content: string): string => {
+ return content
+ .replace(/\s+/g, ' ') // Replace multiple spaces with single space
+ .replace(/\n\s*\n/g, '\n\n') // Normalize paragraph breaks
+ .trim();
+ },
+
+ /**
+ * Extract preview text from content
+ */
+ extractPreview: (content: string, maxLength = 150): string => {
+ const cleaned = content.replace(/[#*_`]/g, '').trim();
+ return truncateAtWordBoundary(cleaned, maxLength);
+ },
+};
diff --git a/src/ai/text/utils/web-content-analyzer-config.ts b/src/ai/text/utils/web-content-analyzer-config.ts
new file mode 100644
index 0000000..784acef
--- /dev/null
+++ b/src/ai/text/utils/web-content-analyzer-config.ts
@@ -0,0 +1,147 @@
+/**
+ * Web Content Analyzer Configuration
+ *
+ * This file contains configuration settings for the web content analyzer feature,
+ * including credit costs and other operational parameters.
+ */
+
+export const webContentAnalyzerConfig = {
+ /**
+ * Credit cost for performing a web content analysis
+ */
+ creditsCost: 100,
+
+ /**
+ * Maximum content length for AI analysis (in characters)
+ * Optimized to prevent token limit issues while maintaining quality
+ */
+ maxContentLength: 8000,
+
+ /**
+ * Content truncation settings for performance optimization
+ */
+ contentTruncation: {
+ /**
+ * Preferred truncation point as percentage of max length
+ * Try to truncate at sentence boundaries when possible
+ */
+ preferredTruncationPoint: 0.8,
+
+ /**
+ * Minimum content length to consider for truncation
+ */
+ minContentLength: 1000,
+
+ /**
+ * Maximum number of sentences to preserve when truncating
+ */
+ maxSentences: 50,
+ },
+
+ /**
+ * Request timeout in milliseconds
+ */
+ timeoutMillis: 55 * 1000, // 55 seconds
+
+ /**
+ * Performance optimization settings
+ */
+ performance: {
+ /**
+ * Debounce delay for URL input (in milliseconds)
+ */
+ urlInputDebounceMs: 500,
+
+ /**
+ * Image lazy loading threshold (intersection observer)
+ */
+ lazyLoadingThreshold: 0.1,
+
+ /**
+ * Maximum number of retry attempts for failed requests
+ */
+ maxRetryAttempts: 3,
+
+ /**
+ * Delay between retry attempts (in milliseconds)
+ */
+ retryDelayMs: 1000,
+ },
+
+ /**
+ * Firecrawl API configuration and scraping options
+ */
+ firecrawl: {
+ // API Configuration
+ apiKey: process.env.FIRECRAWL_API_KEY,
+ baseUrl: 'https://api.firecrawl.dev',
+
+ // Default scraping options
+ formats: ['markdown', 'screenshot'],
+ includeTags: ['title', 'meta', 'h1', 'h2', 'h3', 'p', 'article'],
+ excludeTags: ['script', 'style', 'nav', 'footer', 'aside'],
+ onlyMainContent: true,
+ waitFor: 2000,
+
+ // Screenshot optimization settings
+ screenshot: {
+ quality: 80, // Reduce quality for faster loading
+ fullPage: false, // Only capture viewport for performance
+ },
+
+ // Rate limiting and timeout settings
+ rateLimit: {
+ maxConcurrentRequests: 3,
+ requestDelay: 1000, // 1 second between requests
+ },
+
+ // Maximum content size (in characters)
+ maxContentSize: 100000, // 100KB of text content
+ },
+
+ /**
+ * OpenAI analysis options
+ */
+ openai: {
+ model: 'gpt-4o-mini',
+ temperature: 0.1, // Low temperature for consistent results
+ /**
+ * Token optimization settings
+ */
+ maxTokens: 2000, // Limit response tokens for performance
+ },
+} as const;
+
+/**
+ * Get the credit cost for web content analysis
+ */
+export function getWebContentAnalysisCost(): number {
+ return webContentAnalyzerConfig.creditsCost;
+}
+
+/**
+ * Validates if the Firecrawl API key is configured
+ */
+export function validateFirecrawlConfig(): boolean {
+ if (!webContentAnalyzerConfig.firecrawl.apiKey) {
+ console.warn(
+ 'FIRECRAWL_API_KEY is not configured. Web content analysis features will not work.'
+ );
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Validate if the web content analyzer is properly configured
+ */
+export function validateWebContentAnalyzerConfig(): boolean {
+ return (
+ typeof webContentAnalyzerConfig.creditsCost === 'number' &&
+ webContentAnalyzerConfig.creditsCost > 0 &&
+ typeof webContentAnalyzerConfig.maxContentLength === 'number' &&
+ webContentAnalyzerConfig.maxContentLength > 0 &&
+ typeof webContentAnalyzerConfig.timeoutMillis === 'number' &&
+ webContentAnalyzerConfig.timeoutMillis > 0
+ );
+}
diff --git a/src/ai/text/utils/web-content-analyzer.ts b/src/ai/text/utils/web-content-analyzer.ts
new file mode 100644
index 0000000..a0929d8
--- /dev/null
+++ b/src/ai/text/utils/web-content-analyzer.ts
@@ -0,0 +1,199 @@
+import { z } from 'zod';
+
+// Core Analysis Results Interface
+export interface AnalysisResults {
+ title: string;
+ description: string;
+ introduction: string;
+ features: string[];
+ pricing: string;
+ useCases: string[];
+ url: string;
+ analyzedAt: string;
+}
+
+// API Request/Response Interfaces
+export interface AnalyzeContentRequest {
+ url: string;
+}
+
+export interface AnalyzeContentResponse {
+ success: boolean;
+ data?: {
+ analysis: AnalysisResults;
+ screenshot?: string;
+ };
+ error?: string;
+ creditsConsumed?: number;
+}
+
+// Firecrawl Response Type Definitions
+export interface FirecrawlResponse {
+ success: boolean;
+ data?: {
+ markdown: string;
+ screenshot?: string;
+ metadata?: {
+ title?: string;
+ description?: string;
+ url?: string;
+ ogTitle?: string;
+ ogDescription?: string;
+ ogImage?: string;
+ };
+ };
+ error?: string;
+}
+
+export interface FirecrawlScrapeOptions {
+ formats?: ('markdown' | 'html' | 'rawHtml' | 'screenshot')[];
+ includeTags?: string[];
+ excludeTags?: string[];
+ onlyMainContent?: boolean;
+ screenshot?: boolean;
+ fullPageScreenshot?: boolean;
+ waitFor?: number;
+}
+
+// Analysis State Interface for Component State Management
+export interface AnalysisState {
+ url: string;
+ isLoading: boolean;
+ loadingStage: 'scraping' | 'analyzing' | null;
+ results: AnalysisResults | null;
+ error: string | null;
+ screenshot?: string;
+}
+
+// Component Props Interfaces
+export interface WebContentAnalyzerProps {
+ className?: string;
+}
+
+export interface UrlInputFormProps {
+ onSubmit: (url: string) => void;
+ isLoading: boolean;
+ disabled?: boolean;
+}
+
+export interface AnalysisResultsProps {
+ results: AnalysisResults;
+ screenshot?: string;
+ onNewAnalysis: () => void;
+}
+
+export interface LoadingStatesProps {
+ stage: 'scraping' | 'analyzing';
+ url?: string;
+}
+
+// Zod Validation Schemas
+
+// URL Validation Schema
+export const urlSchema = z
+ .string()
+ .min(1, 'URL is required')
+ .url('Please enter a valid URL')
+ .refine(
+ (url) => url.startsWith('http://') || url.startsWith('https://'),
+ 'URL must start with http:// or https://'
+ );
+
+// Analysis Results Schema
+export const analysisResultsSchema = z.object({
+ title: z.string().min(1, 'Title is required'),
+ description: z.string().min(1, 'Description is required'),
+ introduction: z.string().min(1, 'Introduction is required'),
+ features: z.array(z.string()).default([]),
+ pricing: z.string().default('Not specified'),
+ useCases: z.array(z.string()).default([]),
+ url: urlSchema,
+ analyzedAt: z.string().datetime(),
+});
+
+// API Request Schema
+export const analyzeContentRequestSchema = z.object({
+ url: urlSchema,
+});
+
+// API Response Schema
+export const analyzeContentResponseSchema = z.object({
+ success: z.boolean(),
+ data: z
+ .object({
+ analysis: analysisResultsSchema,
+ screenshot: z.string().optional(),
+ })
+ .optional(),
+ error: z.string().optional(),
+ creditsConsumed: z.number().optional(),
+});
+
+// Firecrawl Response Schema
+export const firecrawlResponseSchema = z.object({
+ success: z.boolean(),
+ data: z
+ .object({
+ markdown: z.string(),
+ screenshot: z.string().optional(),
+ metadata: z
+ .object({
+ title: z.string().optional(),
+ description: z.string().optional(),
+ url: z.string().optional(),
+ ogTitle: z.string().optional(),
+ ogDescription: z.string().optional(),
+ ogImage: z.string().optional(),
+ })
+ .optional(),
+ })
+ .optional(),
+ error: z.string().optional(),
+});
+
+// Firecrawl Scrape Options Schema
+export const firecrawlScrapeOptionsSchema = z.object({
+ formats: z
+ .array(z.enum(['markdown', 'html', 'rawHtml', 'screenshot']))
+ .optional(),
+ includeTags: z.array(z.string()).optional(),
+ excludeTags: z.array(z.string()).optional(),
+ onlyMainContent: z.boolean().optional(),
+ screenshot: z.boolean().optional(),
+ fullPageScreenshot: z.boolean().optional(),
+ waitFor: z.number().optional(),
+});
+
+// Type exports for Zod inferred types
+export type UrlInput = z.infer;
+export type AnalyzeContentRequestInput = z.infer<
+ typeof analyzeContentRequestSchema
+>;
+export type AnalyzeContentResponseInput = z.infer<
+ typeof analyzeContentResponseSchema
+>;
+export type FirecrawlResponseInput = z.infer;
+export type FirecrawlScrapeOptionsInput = z.infer<
+ typeof firecrawlScrapeOptionsSchema
+>;
+
+// Validation helper functions
+export const validateUrl = (url: string) => {
+ return urlSchema.safeParse(url);
+};
+
+export const validateAnalyzeContentRequest = (data: unknown) => {
+ return analyzeContentRequestSchema.safeParse(data);
+};
+
+export const validateAnalyzeContentResponse = (data: unknown) => {
+ return analyzeContentResponseSchema.safeParse(data);
+};
+
+export const validateFirecrawlResponse = (data: unknown) => {
+ return firecrawlResponseSchema.safeParse(data);
+};
+
+export const validateAnalysisResults = (data: unknown) => {
+ return analysisResultsSchema.safeParse(data);
+};
diff --git a/src/app/[locale]/(marketing)/ai/text/page.tsx b/src/app/[locale]/(marketing)/ai/text/page.tsx
index 5702bfc..248e850 100644
--- a/src/app/[locale]/(marketing)/ai/text/page.tsx
+++ b/src/app/[locale]/(marketing)/ai/text/page.tsx
@@ -1,7 +1,7 @@
-import { ConsumeCreditCard } from '@/ai/text/components/consume-credit-card';
-import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
+import { WebContentAnalyzer } from '@/ai/text/components/web-content-analyzer';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
+import { BotIcon, FileTextIcon, GlobeIcon, ZapIcon } from 'lucide-react';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
@@ -26,32 +26,66 @@ export default async function AITextPage() {
const t = await getTranslations('AITextPage');
return (
-
- {/* about section */}
-
-
-
- {/* avatar and name */}
-
-
-
-
-
-
-
-
-
-
{t('content')}
-
-
+
+
+ {/* Header Section */}
+
+
+
+ {t('title')}
- {/* simulate consume credits */}
-
+
+ {t('analyzer.title')}
+
+
+
+ {t('subtitle')}
+
+
+
+ {/* Web Content Analyzer Component */}
+
+
+
+
+ {/* Features Section */}
+
+
+
+
+
+
+ {t('features.scraping.title')}
+
+
+ {t('features.scraping.description')}
+
+
+
+
+
+
+
+
+ {t('features.analysis.title')}
+
+
+ {t('features.analysis.description')}
+
+
+
+
+
+
+
+
+ {t('features.results.title')}
+
+
+ {t('features.results.description')}
+
+
diff --git a/src/app/api/analyze-content/route.ts b/src/app/api/analyze-content/route.ts
new file mode 100644
index 0000000..455c29d
--- /dev/null
+++ b/src/app/api/analyze-content/route.ts
@@ -0,0 +1,490 @@
+import {
+ ErrorSeverity,
+ ErrorType,
+ WebContentAnalyzerError,
+ classifyError,
+ logError,
+ withRetry,
+} from '@/ai/text/utils/error-handling';
+import {
+ type AnalysisResults,
+ type AnalyzeContentResponse,
+ analyzeContentRequestSchema,
+ validateUrl,
+} from '@/ai/text/utils/web-content-analyzer';
+import {
+ getWebContentAnalysisCost,
+ validateFirecrawlConfig,
+ webContentAnalyzerConfig,
+} from '@/ai/text/utils/web-content-analyzer-config';
+import { consumeCredits, hasEnoughCredits } from '@/credits/credits';
+import { getSession } from '@/lib/server';
+import { openai } from '@ai-sdk/openai';
+import FirecrawlApp from '@mendable/firecrawl-js';
+import { generateObject } from 'ai';
+import { type NextRequest, NextResponse } from 'next/server';
+import { z } from 'zod';
+
+// Constants from configuration
+const TIMEOUT_MILLIS = webContentAnalyzerConfig.timeoutMillis;
+const CREDITS_COST = getWebContentAnalysisCost();
+const MAX_CONTENT_LENGTH = webContentAnalyzerConfig.maxContentLength;
+
+// Initialize Firecrawl client
+const getFirecrawlClient = () => {
+ if (!validateFirecrawlConfig()) {
+ throw new Error('Firecrawl API key is not configured');
+ }
+ return new FirecrawlApp({
+ apiKey: webContentAnalyzerConfig.firecrawl.apiKey,
+ });
+};
+
+// AI analysis schema for structured output
+const analysisSchema = z.object({
+ title: z.string().describe('Main title or product name from the webpage'),
+ description: z.string().describe('Brief description in 1-2 sentences'),
+ introduction: z
+ .string()
+ .describe('Detailed introduction paragraph about the content'),
+ features: z.array(z.string()).describe('List of key features or highlights'),
+ pricing: z
+ .string()
+ .describe('Pricing information or "Not specified" if unavailable'),
+ useCases: z.array(z.string()).describe('List of use cases or applications'),
+});
+
+// Timeout wrapper
+const withTimeout =
(
+ promise: Promise,
+ timeoutMillis: number
+): Promise => {
+ return Promise.race([
+ promise,
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('Request timed out')), timeoutMillis)
+ ),
+ ]);
+};
+
+// Enhanced content truncation with intelligent boundary detection
+const truncateContent = (content: string, maxLength: number): string => {
+ if (content.length <= maxLength) {
+ return content;
+ }
+
+ const { contentTruncation } = webContentAnalyzerConfig;
+ const preferredLength = Math.floor(
+ maxLength * contentTruncation.preferredTruncationPoint
+ );
+
+ // If content is shorter than minimum threshold, use simple truncation
+ if (content.length < contentTruncation.minContentLength) {
+ return content.substring(0, maxLength) + '...';
+ }
+
+ // Try to find the best truncation point
+ const truncated = content.substring(0, preferredLength);
+
+ // First, try to truncate at sentence boundaries
+ const sentences = content.split(/[.!?]+/);
+ if (sentences.length > 1) {
+ let sentenceLength = 0;
+ let sentenceCount = 0;
+
+ for (const sentence of sentences) {
+ const nextLength = sentenceLength + sentence.length + 1; // +1 for punctuation
+
+ if (
+ nextLength > maxLength ||
+ sentenceCount >= contentTruncation.maxSentences
+ ) {
+ break;
+ }
+
+ sentenceLength = nextLength;
+ sentenceCount++;
+ }
+
+ if (sentenceLength > preferredLength) {
+ return sentences.slice(0, sentenceCount).join('.') + '.';
+ }
+ }
+
+ // If sentence boundary doesn't work well, try paragraph boundaries
+ const paragraphs = content.split(/\n\s*\n/);
+ if (paragraphs.length > 1) {
+ let paragraphLength = 0;
+
+ for (let i = 0; i < paragraphs.length; i++) {
+ const nextLength = paragraphLength + paragraphs[i].length + 2; // +2 for \n\n
+
+ if (nextLength > maxLength) {
+ break;
+ }
+
+ paragraphLength = nextLength;
+
+ if (paragraphLength > preferredLength) {
+ return paragraphs.slice(0, i + 1).join('\n\n');
+ }
+ }
+ }
+
+ // Fallback to word boundary truncation
+ const words = truncated.split(' ');
+ const lastCompleteWord = words.slice(0, -1).join(' ');
+
+ if (lastCompleteWord.length > preferredLength) {
+ return lastCompleteWord + '...';
+ }
+
+ // Final fallback to character truncation
+ return content.substring(0, maxLength) + '...';
+};
+
+// Scrape webpage using Firecrawl with retry logic
+async function scrapeWebpage(
+ url: string
+): Promise<{ content: string; screenshot?: string }> {
+ return withRetry(async () => {
+ const firecrawl = getFirecrawlClient();
+
+ try {
+ const scrapeResponse = await firecrawl.scrapeUrl(url, {
+ formats: ['markdown', 'screenshot'],
+ onlyMainContent: webContentAnalyzerConfig.firecrawl.onlyMainContent,
+ waitFor: webContentAnalyzerConfig.firecrawl.waitFor,
+ });
+
+ if (!scrapeResponse.success) {
+ throw new WebContentAnalyzerError(
+ ErrorType.SCRAPING,
+ scrapeResponse.error || 'Failed to scrape webpage',
+ 'Unable to access the webpage. Please check the URL and try again.',
+ ErrorSeverity.MEDIUM,
+ true
+ );
+ }
+
+ const content = scrapeResponse.markdown || '';
+ const screenshot = scrapeResponse.screenshot;
+
+ if (!content.trim()) {
+ throw new WebContentAnalyzerError(
+ ErrorType.SCRAPING,
+ 'No content found on the webpage',
+ 'The webpage appears to be empty or inaccessible. Please try a different URL.',
+ ErrorSeverity.MEDIUM,
+ false
+ );
+ }
+
+ return {
+ content: truncateContent(content, MAX_CONTENT_LENGTH),
+ screenshot,
+ };
+ } catch (error) {
+ if (error instanceof WebContentAnalyzerError) {
+ throw error;
+ }
+
+ // Classify and throw the error
+ throw classifyError(error);
+ }
+ });
+}
+
+// Analyze content using OpenAI with retry logic
+async function analyzeContent(
+ content: string,
+ url: string
+): Promise {
+ return withRetry(async () => {
+ try {
+ const { object } = await generateObject({
+ model: openai(webContentAnalyzerConfig.openai.model),
+ schema: analysisSchema,
+ prompt: `
+ Analyze the following webpage content and extract structured information.
+
+ URL: ${url}
+ Content: ${content}
+
+ Please provide accurate and relevant information based on the content. If certain information is not available, use appropriate defaults:
+ - For pricing: use "Not specified" if no pricing information is found
+ - For features and use cases: provide empty arrays if none are found
+ - Ensure the title and description are meaningful and based on the actual content
+ `,
+ temperature: webContentAnalyzerConfig.openai.temperature,
+ maxTokens: webContentAnalyzerConfig.openai.maxTokens,
+ });
+
+ return {
+ ...object,
+ url,
+ analyzedAt: new Date().toISOString(),
+ };
+ } catch (error) {
+ if (error instanceof WebContentAnalyzerError) {
+ throw error;
+ }
+
+ // Check for specific OpenAI/AI errors
+ if (error instanceof Error) {
+ const message = error.message.toLowerCase();
+
+ if (message.includes('rate limit') || message.includes('quota')) {
+ throw new WebContentAnalyzerError(
+ ErrorType.RATE_LIMIT,
+ error.message,
+ 'AI service is temporarily overloaded. Please wait a moment and try again.',
+ ErrorSeverity.MEDIUM,
+ true,
+ error
+ );
+ }
+
+ if (message.includes('timeout') || message.includes('aborted')) {
+ throw new WebContentAnalyzerError(
+ ErrorType.TIMEOUT,
+ error.message,
+ 'AI analysis timed out. Please try again with a shorter webpage.',
+ ErrorSeverity.MEDIUM,
+ true,
+ error
+ );
+ }
+ }
+
+ // Classify and throw the error
+ throw classifyError(error);
+ }
+ });
+}
+
+export async function POST(req: NextRequest) {
+ const requestId = Math.random().toString(36).substring(7);
+ const startTime = performance.now();
+
+ try {
+ // Parse and validate request
+ const body = await req.json();
+ const validationResult = analyzeContentRequestSchema.safeParse(body);
+
+ if (!validationResult.success) {
+ const validationError = new WebContentAnalyzerError(
+ ErrorType.VALIDATION,
+ 'Invalid request parameters',
+ 'Please provide a valid URL.',
+ ErrorSeverity.MEDIUM,
+ false
+ );
+
+ logError(validationError, {
+ requestId,
+ validationErrors: validationResult.error,
+ });
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: validationError.userMessage,
+ } satisfies AnalyzeContentResponse,
+ { status: 400 }
+ );
+ }
+
+ const { url } = validationResult.data;
+
+ // Additional URL validation
+ const urlValidation = validateUrl(url);
+ if (!urlValidation.success) {
+ const urlError = new WebContentAnalyzerError(
+ ErrorType.VALIDATION,
+ urlValidation.error.errors[0]?.message || 'Invalid URL',
+ 'Please enter a valid URL starting with http:// or https://',
+ ErrorSeverity.MEDIUM,
+ false
+ );
+
+ logError(urlError, { requestId, url });
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: urlError.userMessage,
+ } satisfies AnalyzeContentResponse,
+ { status: 400 }
+ );
+ }
+
+ // Check authentication
+ const session = await getSession();
+ if (!session) {
+ const authError = new WebContentAnalyzerError(
+ ErrorType.AUTHENTICATION,
+ 'Authentication required',
+ 'Please sign in to analyze web content.',
+ ErrorSeverity.HIGH,
+ false
+ );
+
+ logError(authError, { requestId });
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: authError.userMessage,
+ } satisfies AnalyzeContentResponse,
+ { status: 401 }
+ );
+ }
+
+ // Check if Firecrawl is configured
+ if (!validateFirecrawlConfig()) {
+ const configError = new WebContentAnalyzerError(
+ ErrorType.SERVICE_UNAVAILABLE,
+ 'Firecrawl API key is not configured',
+ 'Web content analysis service is temporarily unavailable.',
+ ErrorSeverity.CRITICAL,
+ false
+ );
+
+ logError(configError, { requestId });
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: configError.userMessage,
+ } satisfies AnalyzeContentResponse,
+ { status: 503 }
+ );
+ }
+
+ // Check if user has sufficient credits before starting analysis
+ const hasCredits = await hasEnoughCredits({
+ userId: session.user.id,
+ requiredCredits: CREDITS_COST,
+ });
+
+ if (!hasCredits) {
+ const creditError = new WebContentAnalyzerError(
+ ErrorType.CREDITS,
+ 'Insufficient credits to perform analysis',
+ "You don't have enough credits to analyze this webpage. Please purchase more credits.",
+ ErrorSeverity.HIGH,
+ false
+ );
+
+ logError(creditError, {
+ requestId,
+ userId: session.user.id,
+ requiredCredits: CREDITS_COST,
+ });
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: creditError.userMessage,
+ } satisfies AnalyzeContentResponse,
+ { status: 402 }
+ );
+ }
+
+ console.log(
+ `Starting analysis [requestId=${requestId}, url=${url}, userId=${session.user.id}]`
+ );
+
+ // Perform analysis with timeout and enhanced error handling
+ const analysisPromise = (async () => {
+ try {
+ // Step 1: Scrape webpage
+ const { content, screenshot } = await scrapeWebpage(url);
+
+ // Step 2: Analyze content with AI
+ const analysis = await analyzeContent(content, url);
+
+ // Step 3: Consume credits (only on successful analysis)
+ await consumeCredits({
+ userId: session.user.id,
+ amount: CREDITS_COST,
+ description: `Web content analysis: ${url}`,
+ });
+
+ return { analysis, screenshot };
+ } catch (error) {
+ // If it's already a WebContentAnalyzerError, just re-throw
+ if (error instanceof WebContentAnalyzerError) {
+ throw error;
+ }
+
+ // Otherwise classify the error
+ throw classifyError(error);
+ }
+ })();
+
+ // Apply timeout wrapper
+ const result = await withTimeout(analysisPromise, TIMEOUT_MILLIS);
+
+ const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
+ console.log(
+ `Analysis completed [requestId=${requestId}, elapsed=${elapsed}s]`
+ );
+
+ return NextResponse.json({
+ success: true,
+ data: result,
+ creditsConsumed: CREDITS_COST,
+ } satisfies AnalyzeContentResponse);
+ } catch (error) {
+ const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
+
+ // Classify the error if it's not already a WebContentAnalyzerError
+ const analyzedError =
+ error instanceof WebContentAnalyzerError ? error : classifyError(error);
+
+ // Log the error with context
+ logError(analyzedError, {
+ requestId,
+ elapsed: `${elapsed}s`,
+ url: req.url,
+ });
+
+ // Determine status code based on error type
+ let statusCode = 500;
+ switch (analyzedError.type) {
+ case ErrorType.VALIDATION:
+ statusCode = 400;
+ break;
+ case ErrorType.AUTHENTICATION:
+ statusCode = 401;
+ break;
+ case ErrorType.CREDITS:
+ statusCode = 402;
+ break;
+ case ErrorType.TIMEOUT:
+ statusCode = 408;
+ break;
+ case ErrorType.SCRAPING:
+ statusCode = 422;
+ break;
+ case ErrorType.RATE_LIMIT:
+ statusCode = 429;
+ break;
+ case ErrorType.SERVICE_UNAVAILABLE:
+ statusCode = 503;
+ break;
+ default:
+ statusCode = 500;
+ }
+
+ return NextResponse.json(
+ {
+ success: false,
+ error: analyzedError.userMessage,
+ } satisfies AnalyzeContentResponse,
+ { status: statusCode }
+ );
+ }
+}