Merge remote-tracking branch 'origin/main' into cloudflare

This commit is contained in:
javayhu 2025-08-26 00:50:03 +08:00
commit 613bbd0d78
38 changed files with 86 additions and 1247 deletions

View File

@ -1,4 +1,6 @@
.cursor .cursor
.claude
.kiro
.github .github
.next .next
.open-next .open-next
@ -10,4 +12,4 @@
node_modules node_modules
**/node_modules **/node_modules
Dockerfile Dockerfile
LICENSE LICENSE

View File

@ -21,7 +21,7 @@ If you found anything that could be improved, please let me know.
- 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs) - 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs)
- 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap) - 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap)
- 👨‍💻 discord: [mksaas.link/discord](https://mksaas.link/discord) - 👨‍💻 discord: [mksaas.link/discord](https://mksaas.link/discord)
- 📹 video (WIP): [mksaas.link/youtube](https://mksaas.link/youtube) - 📹 video: [mksaas.link/youtube](https://mksaas.link/youtube)
## Repositories ## Repositories

View File

@ -12,6 +12,8 @@
".open-next/**", ".open-next/**",
".wrangler/**", ".wrangler/**",
".cursor/**", ".cursor/**",
".claude/**",
".kiro/**",
".vscode/**", ".vscode/**",
".source/**", ".source/**",
"node_modules/**", "node_modules/**",
@ -27,8 +29,7 @@
"src/app/[[]locale]/preview/**", "src/app/[[]locale]/preview/**",
"src/payment/types.ts", "src/payment/types.ts",
"src/credits/types.ts", "src/credits/types.ts",
"src/types/index.d.ts", "src/types/index.d.ts"
"public/sw.js"
] ]
}, },
"formatter": { "formatter": {
@ -75,6 +76,8 @@
".open-next/**", ".open-next/**",
".wrangler/**", ".wrangler/**",
".cursor/**", ".cursor/**",
".claude/**",
".kiro/**",
".vscode/**", ".vscode/**",
".source/**", ".source/**",
"node_modules/**", "node_modules/**",
@ -90,8 +93,7 @@
"src/app/[[]locale]/preview/**", "src/app/[[]locale]/preview/**",
"src/payment/types.ts", "src/payment/types.ts",
"src/credits/types.ts", "src/credits/types.ts",
"src/types/index.d.ts", "src/types/index.d.ts"
"public/sw.js"
] ]
}, },
"javascript": { "javascript": {

View File

@ -87,7 +87,6 @@
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0", "@vercel/speed-insights": "^1.2.0",
"@widgetbot/react-embed": "^1.9.0",
"ai": "^5.0.0", "ai": "^5.0.0",
"better-auth": "^1.1.19", "better-auth": "^1.1.19",
"canvas-confetti": "^1.9.3", "canvas-confetti": "^1.9.3",

48
pnpm-lock.yaml generated
View File

@ -191,9 +191,6 @@ importers:
'@vercel/speed-insights': '@vercel/speed-insights':
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.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) version: 1.2.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)
'@widgetbot/react-embed':
specifier: ^1.9.0
version: 1.9.0(react@19.0.0)
ai: ai:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.0(zod@4.0.17) version: 5.0.0(zod@4.0.17)
@ -4824,14 +4821,6 @@ packages:
vue-router: vue-router:
optional: true optional: true
'@widgetbot/embed-api@1.2.17':
resolution: {integrity: sha512-qoiFLMak+mBG64pgKN5xFv3amPHcG2qcurPefAbof4DI/eip5OU59pbM+ak4d9d9OIkwA1QhoDzo9KWD/cOn0w==}
'@widgetbot/react-embed@1.9.0':
resolution: {integrity: sha512-+Qgqy7lwLy++lIiHmSsgxUjwcX80iFIHR0QJpKq4W82ePUmq4bTuxvUbxcE+VQH5IjNrWaydGNR8zROV5vUQsA==}
peerDependencies:
react: '>= 15'
abort-controller@3.0.0: abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'} engines: {node: '>=6.5'}
@ -5157,12 +5146,6 @@ packages:
crisp-sdk-web@1.0.25: crisp-sdk-web@1.0.25:
resolution: {integrity: sha512-CWTHFFeHRV0oqiXoPh/aIAKhFs6xcIM4NenGPnClAMCZUDQgQsF1OWmZWmnVNjJriXUmWRgDfeUxcxygS0dCRA==} resolution: {integrity: sha512-CWTHFFeHRV0oqiXoPh/aIAKhFs6xcIM4NenGPnClAMCZUDQgQsF1OWmZWmnVNjJriXUmWRgDfeUxcxygS0dCRA==}
cross-domain-safe-weakmap@1.0.29:
resolution: {integrity: sha512-VLoUgf2SXnf3+na8NfeUFV59TRZkIJqCIATaMdbhccgtnTlSnHXkyTRwokngEGYdQXx8JbHT9GDYitgR2sdjuA==}
cross-domain-utils@2.0.38:
resolution: {integrity: sha512-zZfi3+2EIR9l4chrEiXI2xFleyacsJf8YMLR1eJ0Veb5FTMXeJ3DpxDjZkto2FhL/g717WSELqbptNSo85UJDw==}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -6947,9 +6930,6 @@ packages:
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
engines: {node: '>=12'} engines: {node: '>=12'}
post-robot@8.0.32:
resolution: {integrity: sha512-PMOdDAt3pyuKUxZcTzdcXXFxLqkdeLpRlcCQl7QAJpI+e7J1YHH+PfC7KAbcL8hRVQ1LknQYGoirbA1/eO/a1g==}
postcss-selector-parser@7.1.0: postcss-selector-parser@7.1.0:
resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -7918,9 +7898,6 @@ packages:
youch@4.1.0-beta.10: youch@4.1.0-beta.10:
resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==}
zalgo-promise@1.0.48:
resolution: {integrity: sha512-LLHANmdm53+MucY9aOFIggzYtUdkSBFxUsy4glTTQYNyK6B3uCPWTbfiGvSrEvLojw0mSzyFJ1/RRLv+QMNdzQ==}
zod-to-json-schema@3.24.2: zod-to-json-schema@3.24.2:
resolution: {integrity: sha512-pNUqrcSxuuB3/+jBbU8qKUbTbDqYUaG1vf5cXFjbhGgoUuA1amO/y4Q8lzfOhHU8HNPK6VFJ18lBDKj3OHyDsg==} resolution: {integrity: sha512-pNUqrcSxuuB3/+jBbU8qKUbTbDqYUaG1vf5cXFjbhGgoUuA1amO/y4Q8lzfOhHU8HNPK6VFJ18lBDKj3OHyDsg==}
peerDependencies: peerDependencies:
@ -13117,15 +13094,6 @@ snapshots:
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) 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 react: 19.0.0
'@widgetbot/embed-api@1.2.17':
dependencies:
post-robot: 8.0.32
'@widgetbot/react-embed@1.9.0(react@19.0.0)':
dependencies:
'@widgetbot/embed-api': 1.2.17
react: 19.0.0
abort-controller@3.0.0: abort-controller@3.0.0:
dependencies: dependencies:
event-target-shim: 5.0.1 event-target-shim: 5.0.1
@ -13459,14 +13427,6 @@ snapshots:
crisp-sdk-web@1.0.25: {} crisp-sdk-web@1.0.25: {}
cross-domain-safe-weakmap@1.0.29:
dependencies:
cross-domain-utils: 2.0.38
cross-domain-utils@2.0.38:
dependencies:
zalgo-promise: 1.0.48
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -15667,12 +15627,6 @@ snapshots:
picomatch@4.0.2: {} picomatch@4.0.2: {}
post-robot@8.0.32:
dependencies:
cross-domain-safe-weakmap: 1.0.29
cross-domain-utils: 2.0.38
zalgo-promise: 1.0.48
postcss-selector-parser@7.1.0: postcss-selector-parser@7.1.0:
dependencies: dependencies:
cssesc: 3.0.0 cssesc: 3.0.0
@ -16851,8 +16805,6 @@ snapshots:
cookie: 1.0.2 cookie: 1.0.2
youch-core: 0.3.3 youch-core: 0.3.3
zalgo-promise@1.0.48: {}
zod-to-json-schema@3.24.2(zod@3.25.64): zod-to-json-schema@3.24.2(zod@3.25.64):
dependencies: dependencies:
zod: 3.25.64 zod: 3.25.64

View File

@ -1,129 +0,0 @@
// Service Worker for caching iframe content
const CACHE_NAME = 'cnblocks-iframe-cache-v1'
// Add iframe URLs to this list to prioritize caching
const URLS_TO_CACHE = [
// Default assets that should be cached
'/favicon.ico',
// Images used in iframes
'/payments.png',
'/payments-light.png',
'/origin-cal.png',
'/origin-cal-dark.png',
'/exercice.png',
'/exercice-dark.png',
'/charts-light.png',
'/charts.png',
'/music-light.png',
'/music.png',
'/mail-back-light.png',
'/mail-upper.png',
'/mail-back.png',
'/card.png',
'/dark-card.webp',
]
// Install event - cache resources
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
console.log('Opened cache')
return cache.addAll(URLS_TO_CACHE)
})
.then(() => self.skipWaiting()) // Activate SW immediately
)
})
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
const currentCaches = [CACHE_NAME]
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return cacheNames.filter((cacheName) => !currentCaches.includes(cacheName))
})
.then((cachesToDelete) => {
return Promise.all(
cachesToDelete.map((cacheToDelete) => {
return caches.delete(cacheToDelete)
})
)
})
.then(() => self.clients.claim()) // Take control of clients immediately
)
})
// Fetch event - serve from cache or fetch from network and cache
self.addEventListener('fetch', (event) => {
// Check if this is an iframe request - typically they'll be HTML or have 'preview' in the URL
const isIframeRequest = event.request.url.includes('/preview/') || event.request.url.includes('/examples/')
if (isIframeRequest) {
event.respondWith(
caches.match(event.request, { ignoreSearch: true }).then((response) => {
// Return cached response if found
if (response) {
return response
}
// Clone the request (requests are one-time use)
const fetchRequest = event.request.clone()
return fetch(fetchRequest).then((response) => {
// Check if we received a valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response
}
// Clone the response (responses are one-time use)
const responseToCache = response.clone()
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache)
})
return response
})
})
)
} else {
// For non-iframe requests, use a standard cache-first strategy
event.respondWith(
caches.match(event.request).then((response) => {
if (response) {
return response
}
return fetch(event.request)
})
)
}
})
// Listen for messages from clients (to force cache update, etc)
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
// Handle cache clearing
if (event.data && event.data.type === 'CLEAR_IFRAME_CACHE') {
const url = event.data.url
if (url) {
// Clear specific URL from cache
caches.open(CACHE_NAME).then((cache) => {
cache.delete(url).then(() => {
console.log(`Cleared cache for: ${url}`)
})
})
} else {
// Clear the entire cache
caches.delete(CACHE_NAME).then(() => {
console.log('Cleared entire iframe cache')
})
}
}
})

View File

@ -1,37 +0,0 @@
'use server';
import { getWebContentAnalysisCost } from '@/ai/text/utils/web-content-analyzer-config';
import { getUserCredits, hasEnoughCredits } from '@/credits/credits';
import type { User } from '@/lib/auth-types';
import { userActionClient } from '@/lib/safe-action';
/**
* Check if user has enough credits for web content analysis
*/
export const checkWebContentAnalysisCreditsAction = userActionClient.action(
async ({ ctx }) => {
const currentUser = (ctx as { user: User }).user;
try {
const requiredCredits = getWebContentAnalysisCost();
const currentCredits = await getUserCredits(currentUser.id);
const hasCredits = await hasEnoughCredits({
userId: currentUser.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',
};
}
}
);

View File

@ -70,7 +70,7 @@ export default function ChatBot() {
}; };
return ( return (
<div className="max-w-4xl mx-auto p-6 relative size-full h-screen rounded-lg bg-muted/50"> <div className="mx-auto p-6 relative size-full h-screen rounded-lg bg-muted/50">
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
<Conversation className="h-full"> <Conversation className="h-full">
<ConversationContent> <ConversationContent>

View File

@ -76,9 +76,9 @@ export function ImagePlayground({
return ( return (
<div className="rounded-lg bg-background py-8 px-4 sm:px-6 lg:px-8"> <div className="rounded-lg bg-background py-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto"> <div className="mx-auto">
{/* header */} {/* header */}
<ImageGeneratorHeader /> {/* <ImageGeneratorHeader /> */}
{/* input prompt */} {/* input prompt */}
<PromptInput <PromptInput

View File

@ -1,57 +0,0 @@
'use client';
import { CreditsBalanceButton } from '@/components/layout/credits-balance-button';
import { Button } from '@/components/ui/button';
import { useConsumeCredits, useCreditBalance } from '@/hooks/use-credits';
import { CoinsIcon } from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
const CONSUME_CREDITS = 50;
export function ConsumeCreditCard() {
const { data: balance = 0, isLoading: isLoadingBalance } = useCreditBalance();
const consumeCreditsMutation = useConsumeCredits();
const [loading, setLoading] = useState(false);
const hasEnoughCredits = (amount: number) => balance >= amount;
const handleConsume = async () => {
if (!hasEnoughCredits(CONSUME_CREDITS)) {
toast.error('Insufficient credits, please buy more credits.');
return;
}
setLoading(true);
try {
await consumeCreditsMutation.mutateAsync({
amount: CONSUME_CREDITS,
description: `AI Text Credit Consumption (${CONSUME_CREDITS} credits)`,
});
toast.success(`${CONSUME_CREDITS} credits have been consumed.`);
} catch (error) {
toast.error('Failed to consume credits, please try again later.');
} finally {
setLoading(false);
}
};
return (
<div className="flex flex-col items-center gap-8 p-4 border rounded-lg">
<div className="w-full flex flex-row items-center justify-end">
<CreditsBalanceButton />
</div>
<Button
variant="outline"
size="sm"
onClick={handleConsume}
disabled={
loading || isLoadingBalance || consumeCreditsMutation.isPending
}
className="w-full cursor-pointer"
>
<CoinsIcon className="size-4" />
<span>Consume {CONSUME_CREDITS} credits</span>
</Button>
</div>
);
}

View File

@ -34,7 +34,6 @@ interface ErrorDisplayProps {
const errorIcons = { const errorIcons = {
[ErrorType.VALIDATION]: AlertCircleIcon, [ErrorType.VALIDATION]: AlertCircleIcon,
[ErrorType.NETWORK]: WifiOffIcon, [ErrorType.NETWORK]: WifiOffIcon,
[ErrorType.CREDITS]: CreditCardIcon,
[ErrorType.SCRAPING]: ServerIcon, [ErrorType.SCRAPING]: ServerIcon,
[ErrorType.ANALYSIS]: HelpCircleIcon, [ErrorType.ANALYSIS]: HelpCircleIcon,
[ErrorType.TIMEOUT]: ClockIcon, [ErrorType.TIMEOUT]: ClockIcon,
@ -84,7 +83,6 @@ const severityColors = {
const errorTitles = { const errorTitles = {
[ErrorType.VALIDATION]: 'Invalid Input', [ErrorType.VALIDATION]: 'Invalid Input',
[ErrorType.NETWORK]: 'Connection Error', [ErrorType.NETWORK]: 'Connection Error',
[ErrorType.CREDITS]: 'Insufficient Credits',
[ErrorType.SCRAPING]: 'Unable to Access Website', [ErrorType.SCRAPING]: 'Unable to Access Website',
[ErrorType.ANALYSIS]: 'Analysis Failed', [ErrorType.ANALYSIS]: 'Analysis Failed',
[ErrorType.TIMEOUT]: 'Request Timed Out', [ErrorType.TIMEOUT]: 'Request Timed Out',

View File

@ -1,5 +1,4 @@
export { AnalysisResults } from './analysis-results'; export { AnalysisResults } from './analysis-results';
export { ConsumeCreditCard } from './consume-credit-card';
export { LoadingStates } from './loading-states'; export { LoadingStates } from './loading-states';
export { UrlInputForm } from './url-input-form'; export { UrlInputForm } from './url-input-form';
export { WebContentAnalyzer } from './web-content-analyzer'; export { WebContentAnalyzer } from './web-content-analyzer';

View File

@ -1,9 +1,7 @@
'use client'; 'use client';
import { checkWebContentAnalysisCreditsAction } from '@/actions/check-web-content-analysis-credits';
import type { UrlInputFormProps } from '@/ai/text/utils/web-content-analyzer'; import type { UrlInputFormProps } from '@/ai/text/utils/web-content-analyzer';
import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config'; import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-config';
import { LoginWrapper } from '@/components/auth/login-wrapper';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
Form, Form,
@ -20,21 +18,10 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useLocalePathname } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { import { LinkIcon, Loader2Icon, SparklesIcon } from 'lucide-react';
AlertCircleIcon,
CoinsIcon,
LinkIcon,
Loader2Icon,
LogInIcon,
SparklesIcon,
} from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod'; import { z } from 'zod';
import { useDebounce } from '../utils/performance'; import { useDebounce } from '../utils/performance';
@ -52,19 +39,9 @@ export function UrlInputForm({
modelProvider, modelProvider,
setModelProvider, setModelProvider,
}: UrlInputFormProps) { }: UrlInputFormProps) {
const [creditInfo, setCreditInfo] = useState<{
hasEnoughCredits: boolean;
currentCredits: number;
requiredCredits: number;
} | null>(null);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Get authentication status and current path for callback // Prevent hydration mismatch by only rendering content after mount
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(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
@ -84,42 +61,6 @@ export function UrlInputForm({
webContentAnalyzerConfig.performance.urlInputDebounceMs 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 // Debounced URL validation effect
useEffect(() => { useEffect(() => {
if (debouncedUrl && debouncedUrl !== urlValue) { if (debouncedUrl && debouncedUrl !== urlValue) {
@ -129,23 +70,12 @@ export function UrlInputForm({
}, [debouncedUrl, urlValue, form]); }, [debouncedUrl, urlValue, form]);
const handleSubmit = (data: UrlFormData) => { 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 ?? '', modelProvider); onSubmit(data.url ?? '', modelProvider);
}; };
const handleFormSubmit = form.handleSubmit(handleSubmit); const handleFormSubmit = form.handleSubmit(handleSubmit);
const isInsufficientCredits = creditInfo && !creditInfo.hasEnoughCredits; const isFormDisabled = isLoading || disabled;
const isFormDisabled = isLoading || disabled || !!isInsufficientCredits;
return ( return (
<> <>
@ -161,10 +91,10 @@ export function UrlInputForm({
<SelectValue placeholder="Select model" /> <SelectValue placeholder="Select model" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="openrouter">OpenRouter</SelectItem>
<SelectItem value="openai">OpenAI GPT-4o</SelectItem> <SelectItem value="openai">OpenAI GPT-4o</SelectItem>
<SelectItem value="gemini">Google Gemini</SelectItem> <SelectItem value="gemini">Google Gemini</SelectItem>
<SelectItem value="deepseek">DeepSeek</SelectItem> <SelectItem value="deepseek">DeepSeek R1</SelectItem>
<SelectItem value="openrouter">OpenRouter</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -194,67 +124,20 @@ export function UrlInputForm({
)} )}
/> />
{/* Credit Information - Only show for authenticated users */}
{isAuthenticated && creditInfo && (
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg text-sm">
<div className="flex items-center gap-2">
<CoinsIcon className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
Cost: {creditInfo.requiredCredits} credits
</span>
</div>
<div className="flex items-center gap-2">
<span
className={
creditInfo.hasEnoughCredits
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}
>
Balance: {creditInfo.currentCredits}
</span>
{!creditInfo.hasEnoughCredits && (
<AlertCircleIcon className="size-4 text-red-600 dark:text-red-400" />
)}
</div>
</div>
)}
{/* Insufficient Credits Warning */}
{isAuthenticated && isInsufficientCredits && (
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-400">
<AlertCircleIcon className="size-4 flex-shrink-0" />
<span>
Insufficient credits. You need {creditInfo.requiredCredits}{' '}
credits but only have {creditInfo.currentCredits}.
</span>
</div>
)}
{!mounted ? ( {!mounted ? (
// Show loading state during hydration to prevent mismatch // Show loading state during hydration to prevent mismatch
<Button type="button" disabled className="w-full" size="lg"> <Button type="button" disabled className="w-full" size="lg">
<Loader2Icon className="size-4 animate-spin" /> <Loader2Icon className="size-4 animate-spin" />
<span>Loading...</span> <span>Loading...</span>
</Button> </Button>
) : isAuthenticated ? ( ) : (
<Button <Button
type="submit" type="submit"
disabled={isFormDisabled || !urlValue?.trim()} disabled={isFormDisabled || !urlValue?.trim()}
className="w-full" className="w-full"
size="lg" size="lg"
> >
{isAuthLoading ? ( {isLoading ? (
<>
<Loader2Icon className="size-4 animate-spin" />
<span>Loading...</span>
</>
) : isCheckingCredits ? (
<>
<Loader2Icon className="size-4 animate-spin" />
<span>Checking Credits...</span>
</>
) : isLoading ? (
<> <>
<Loader2Icon className="size-4 animate-spin" /> <Loader2Icon className="size-4 animate-spin" />
<span>Analyzing...</span> <span>Analyzing...</span>
@ -262,24 +145,10 @@ export function UrlInputForm({
) : ( ) : (
<> <>
<SparklesIcon className="size-4" /> <SparklesIcon className="size-4" />
<span> <span>Analyze Website</span>
Analyze Website
{creditInfo && ` (${creditInfo.requiredCredits} credits)`}
</span>
</> </>
)} )}
</Button> </Button>
) : (
<LoginWrapper mode="modal" asChild callbackUrl={currentPath}>
<Button
type="button"
className="w-full cursor-pointer"
size="lg"
>
<LogInIcon className="size-4" />
<span>Sign In First</span>
</Button>
</LoginWrapper>
)} )}
</form> </form>
</Form> </Form>

View File

@ -194,7 +194,8 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
const [state, dispatch] = useReducer(analysisReducer, initialState); const [state, dispatch] = useReducer(analysisReducer, initialState);
// Model provider state // Model provider state
const [modelProvider, setModelProvider] = useState<ModelProvider>('openai'); const [modelProvider, setModelProvider] =
useState<ModelProvider>('openrouter');
// Enhanced error state // Enhanced error state
const [analyzedError, setAnalyzedError] = const [analyzedError, setAnalyzedError] =
@ -232,16 +233,6 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
errorType = ErrorType.VALIDATION; errorType = ErrorType.VALIDATION;
retryable = false; retryable = false;
break; 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: case 408:
errorType = ErrorType.TIMEOUT; errorType = ErrorType.TIMEOUT;
break; break;

View File

@ -9,7 +9,6 @@ import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-c
export enum ErrorType { export enum ErrorType {
VALIDATION = 'validation', VALIDATION = 'validation',
NETWORK = 'network', NETWORK = 'network',
CREDITS = 'credits',
SCRAPING = 'scraping', SCRAPING = 'scraping',
ANALYSIS = 'analysis', ANALYSIS = 'analysis',
TIMEOUT = 'timeout', TIMEOUT = 'timeout',
@ -96,22 +95,6 @@ export function classifyError(error: unknown): WebContentAnalyzerError {
); );
} }
// 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 // Scraping errors
if ( if (
message.includes('scrape') || message.includes('scrape') ||
@ -278,16 +261,6 @@ export function getRecoveryActions(error: WebContentAnalyzerError): Array<{
{ label: 'Try Simpler URL', action: 'simplify_url' }, { 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: case ErrorType.SCRAPING:
return [ return [
{ label: 'Try Again', action: 'retry', primary: true }, { label: 'Try Again', action: 'retry', primary: true },

View File

@ -6,11 +6,6 @@
*/ */
export const webContentAnalyzerConfig = { export const webContentAnalyzerConfig = {
/**
* Credit cost for performing a web content analysis
*/
creditsCost: 100,
/** /**
* Maximum content length for AI analysis (in characters) * Maximum content length for AI analysis (in characters)
* Optimized to prevent token limit issues while maintaining quality * Optimized to prevent token limit issues while maintaining quality
@ -118,21 +113,15 @@ export const webContentAnalyzerConfig = {
maxTokens: 2000, maxTokens: 2000,
}, },
openrouter: { openrouter: {
model: 'openrouter/horizon-beta', // model: 'openrouter/horizon-beta',
// model: 'x-ai/grok-3-beta', // model: 'x-ai/grok-3-beta',
// model: 'openai/gpt-4o-mini', // model: 'openai/gpt-4o-mini',
model: 'deepseek/deepseek-r1:free',
temperature: 0.1, temperature: 0.1,
maxTokens: 2000, maxTokens: 2000,
}, },
} as const; } 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 * Validates if the Firecrawl API key is configured
*/ */
@ -151,8 +140,6 @@ export function validateFirecrawlConfig(): boolean {
*/ */
export function validateWebContentAnalyzerConfig(): boolean { export function validateWebContentAnalyzerConfig(): boolean {
return ( return (
typeof webContentAnalyzerConfig.creditsCost === 'number' &&
webContentAnalyzerConfig.creditsCost > 0 &&
typeof webContentAnalyzerConfig.maxContentLength === 'number' && typeof webContentAnalyzerConfig.maxContentLength === 'number' &&
webContentAnalyzerConfig.maxContentLength > 0 && webContentAnalyzerConfig.maxContentLength > 0 &&
typeof webContentAnalyzerConfig.timeoutMillis === 'number' && typeof webContentAnalyzerConfig.timeoutMillis === 'number' &&

View File

@ -67,7 +67,7 @@ export interface AnalysisState {
} }
// Component Props Interfaces // Component Props Interfaces
export type ModelProvider = 'openai' | 'gemini' | 'deepseek'; export type ModelProvider = 'openai' | 'gemini' | 'deepseek' | 'openrouter';
export interface WebContentAnalyzerProps { export interface WebContentAnalyzerProps {
className?: string; className?: string;

View File

@ -1,12 +1,12 @@
import Container from '@/components/layout/container'; import Container from '@/components/layout/container';
import { CreditsTest } from '@/components/test/credits-test'; import { ConsumeCreditsCard } from '@/components/test/consume-credits-card';
export default async function TestPage() { export default async function TestPage() {
return ( return (
<Container className="py-16 px-4"> <Container className="py-16 px-4">
<div className="max-w-4xl mx-auto space-y-8"> <div className="max-w-4xl mx-auto space-y-8">
{/* credits test */} {/* credits test */}
<CreditsTest /> <ConsumeCreditsCard />
</div> </div>
</Container> </Container>
); );

View File

@ -37,7 +37,9 @@ export default async function AIChatPage() {
</div> </div>
{/* Chat Bot */} {/* Chat Bot */}
<ChatBot /> <div className="max-w-6xl mx-auto">
<ChatBot />
</div>
</div> </div>
</div> </div>
); );

View File

@ -2,6 +2,7 @@ import { ImagePlayground } from '@/ai/image/components/ImagePlayground';
import { getRandomSuggestions } from '@/ai/image/lib/suggestions'; import { getRandomSuggestions } from '@/ai/image/lib/suggestions';
import { constructMetadata } from '@/lib/metadata'; import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls'; import { getUrlWithLocale } from '@/lib/urls/urls';
import { ImageIcon } from 'lucide-react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import type { Locale } from 'next-intl'; import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
@ -26,8 +27,21 @@ export default async function AIImagePage() {
const t = await getTranslations('AIImagePage'); const t = await getTranslations('AIImagePage');
return ( return (
<div className="mx-auto space-y-8"> <div className="min-h-screen bg-muted/50 rounded-lg">
<ImagePlayground suggestions={getRandomSuggestions(5)} /> <div className="container mx-auto px-4 py-8 md:py-16">
{/* Header Section */}
<div className="text-center space-y-6 mb-12">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary text-sm font-medium">
<ImageIcon className="size-4" />
{t('title')}
</div>
</div>
{/* Image Playground Component */}
<div className="max-w-6xl mx-auto">
<ImagePlayground suggestions={getRandomSuggestions(5)} />
</div>
</div>
</div> </div>
); );
} }

View File

@ -1,16 +0,0 @@
import { categories } from '@/components/tailark/blocks';
import BlocksNav from '@/components/tailark/blocks-nav';
import type { PropsWithChildren } from 'react';
/**
* The locale inconsistency issue has been fixed in the BlocksNav component
*/
export default function BlockCategoryLayout({ children }: PropsWithChildren) {
return (
<>
<BlocksNav categories={categories} />
<main>{children}</main>
</>
);
}

View File

@ -1,54 +0,0 @@
import BlockPreview from '@/components/tailark/block-preview';
import { blocks, categories } from '@/components/tailark/blocks';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
export const dynamic = 'force-static';
export const revalidate = 3600;
export async function generateStaticParams() {
return categories.map((category) => ({
category: category,
}));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: Locale; category: string }>;
}): Promise<Metadata | undefined> {
const { locale, category } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
return constructMetadata({
title: category + ' | ' + t('title'),
description: t('description'),
canonicalUrl: getUrlWithLocale(`/blocks/${category}`, locale),
});
}
interface BlockCategoryPageProps {
params: Promise<{ category: string }>;
}
export default async function BlockCategoryPage({
params,
}: BlockCategoryPageProps) {
const { category } = await params;
const categoryBlocks = blocks.filter((b) => b.category === category);
if (categoryBlocks.length === 0) {
notFound();
}
return (
<>
{categoryBlocks.map((block, index) => (
<BlockPreview {...block} key={index} />
))}
</>
);
}

View File

@ -29,7 +29,7 @@ interface ProvidersProps {
*/ */
export function Providers({ children, locale }: ProvidersProps) { export function Providers({ children, locale }: ProvidersProps) {
const theme = useTheme(); const theme = useTheme();
const defaultMode = websiteConfig.metadata.mode?.defaultMode ?? 'system'; const defaultMode = websiteConfig.ui.mode?.defaultMode ?? 'system';
// available languages that will be displayed in the docs UI // available languages that will be displayed in the docs UI
// make sure `locale` is consistent with your i18n config // make sure `locale` is consistent with your i18n config

View File

@ -13,12 +13,9 @@ import {
validateUrl, validateUrl,
} from '@/ai/text/utils/web-content-analyzer'; } from '@/ai/text/utils/web-content-analyzer';
import { import {
getWebContentAnalysisCost,
validateFirecrawlConfig, validateFirecrawlConfig,
webContentAnalyzerConfig, webContentAnalyzerConfig,
} from '@/ai/text/utils/web-content-analyzer-config'; } from '@/ai/text/utils/web-content-analyzer-config';
import { consumeCredits, hasEnoughCredits } from '@/credits/credits';
import { getSession } from '@/lib/server';
import { createDeepSeek } from '@ai-sdk/deepseek'; import { createDeepSeek } from '@ai-sdk/deepseek';
import { createGoogleGenerativeAI } from '@ai-sdk/google'; import { createGoogleGenerativeAI } from '@ai-sdk/google';
import { createOpenAI } from '@ai-sdk/openai'; import { createOpenAI } from '@ai-sdk/openai';
@ -30,7 +27,6 @@ import { z } from 'zod';
// Constants from configuration // Constants from configuration
const TIMEOUT_MILLIS = webContentAnalyzerConfig.timeoutMillis; const TIMEOUT_MILLIS = webContentAnalyzerConfig.timeoutMillis;
const CREDITS_COST = getWebContentAnalysisCost();
const MAX_CONTENT_LENGTH = webContentAnalyzerConfig.maxContentLength; const MAX_CONTENT_LENGTH = webContentAnalyzerConfig.maxContentLength;
// Initialize Firecrawl client // Initialize Firecrawl client
@ -361,28 +357,6 @@ export async function POST(req: NextRequest) {
); );
} }
// 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 // Check if Firecrawl is configured
if (!validateFirecrawlConfig()) { if (!validateFirecrawlConfig()) {
const configError = new WebContentAnalyzerError( const configError = new WebContentAnalyzerError(
@ -404,39 +378,7 @@ export async function POST(req: NextRequest) {
); );
} }
// Check if user has sufficient credits before starting analysis console.log(`Starting analysis [requestId=${requestId}, url=${url}]`);
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 // Perform analysis with timeout and enhanced error handling
const analysisPromise = (async () => { const analysisPromise = (async () => {
@ -447,13 +389,6 @@ export async function POST(req: NextRequest) {
// Step 2: Analyze content with AI (pass provider) // Step 2: Analyze content with AI (pass provider)
const analysis = await analyzeContent(content, url, modelProvider); const analysis = await analyzeContent(content, url, modelProvider);
// 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 }; return { analysis, screenshot };
} catch (error) { } catch (error) {
// If it's already a WebContentAnalyzerError, just re-throw // If it's already a WebContentAnalyzerError, just re-throw
@ -477,7 +412,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: result, data: result,
creditsConsumed: CREDITS_COST,
} satisfies AnalyzeContentResponse); } satisfies AnalyzeContentResponse);
} catch (error) { } catch (error) {
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1); const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
@ -499,12 +433,6 @@ export async function POST(req: NextRequest) {
case ErrorType.VALIDATION: case ErrorType.VALIDATION:
statusCode = 400; statusCode = 400;
break; break;
case ErrorType.AUTHENTICATION:
statusCode = 401;
break;
case ErrorType.CREDITS:
statusCode = 402;
break;
case ErrorType.TIMEOUT: case ErrorType.TIMEOUT:
statusCode = 408; statusCode = 408;
break; break;

View File

@ -71,7 +71,7 @@ export function SidebarUser({ user, className }: SidebarUserProps) {
}); });
}; };
const showModeSwitch = websiteConfig.metadata.mode?.enableSwitch ?? false; const showModeSwitch = websiteConfig.ui.mode?.enableSwitch ?? false;
const showLocaleSwitch = LOCALES.length > 1; const showLocaleSwitch = LOCALES.length > 1;
const handleSignOut = async () => { const handleSignOut = async () => {

View File

@ -10,7 +10,7 @@ import {
} from 'react'; } from 'react';
const COOKIE_NAME = 'active_theme'; const COOKIE_NAME = 'active_theme';
const DEFAULT_THEME = websiteConfig.metadata.theme?.defaultTheme ?? 'default'; const DEFAULT_THEME = websiteConfig.ui.theme?.defaultTheme ?? 'default';
function setThemeCookie(theme: string) { function setThemeCookie(theme: string) {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;

View File

@ -12,7 +12,7 @@ import { useEffect, useState } from 'react';
* Mode switcher component, used in the footer * Mode switcher component, used in the footer
*/ */
export function ModeSwitcherHorizontal() { export function ModeSwitcherHorizontal() {
if (!websiteConfig.metadata.mode?.enableSwitch) { if (!websiteConfig.ui.mode?.enableSwitch) {
return null; return null;
} }

View File

@ -16,7 +16,7 @@ import { useTheme } from 'next-themes';
* Mode switcher component, used in the navbar * Mode switcher component, used in the navbar
*/ */
export function ModeSwitcher() { export function ModeSwitcher() {
if (!websiteConfig.metadata.mode?.enableSwitch) { if (!websiteConfig.ui.mode?.enableSwitch) {
return null; return null;
} }

View File

@ -21,7 +21,7 @@ import { useThemeConfig } from './active-theme-provider';
* https://github.com/TheOrcDev/orcish-dashboard/blob/main/components/theme-selector.tsx * https://github.com/TheOrcDev/orcish-dashboard/blob/main/components/theme-selector.tsx
*/ */
export function ThemeSelector() { export function ThemeSelector() {
if (!websiteConfig.metadata.theme?.enableSwitch) { if (!websiteConfig.ui.theme?.enableSwitch) {
return null; return null;
} }

View File

@ -1,88 +0,0 @@
'use client';
import { DiscordIcon } from '@/components/icons/discord';
import { websiteConfig } from '@/config/website';
import { useMediaQuery } from '@/hooks/use-media-query';
import WidgetBot from '@widgetbot/react-embed';
import { useEffect, useRef, useState } from 'react';
/**
* Discord Widget, shows the channels and messages in the discord server
*
* @deprecated
* This feature is deprecated for Discord Widget can not be used anymore.
*
* https://docs.widgetbot.io/embed/react-embed/
*/
export default function DiscordWidget() {
if (!websiteConfig.features.enableDiscordWidget) {
return null;
}
const serverId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID as string;
const channelId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID as string;
if (!serverId || !channelId) {
return null;
}
const [open, setOpen] = useState(false);
const widgetRef = useRef<HTMLDivElement>(null);
const { device, width: windowWidth, height: windowHeight } = useMediaQuery();
let widgetWidth = 800;
let widgetHeight = 600;
if (device === 'mobile') {
widgetWidth = windowWidth ? Math.floor(windowWidth * 0.9) : 320;
widgetHeight = windowHeight ? Math.floor(windowHeight * 0.8) : 400;
} else if (device === 'tablet' || device === 'sm') {
widgetWidth = windowWidth ? Math.floor(windowWidth * 0.9) : 600;
widgetHeight = windowHeight ? Math.floor(windowHeight * 0.8) : 480;
}
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (widgetRef.current && !widgetRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
return (
<div>
{/* discord icon button, show in bottom right corner */}
{!open && (
<button
aria-label="Open Discord Widget"
className="fixed bottom-[84px] right-10 z-50 cursor-pointer flex items-center justify-center rounded-full bg-[#5865F2] shadow-lg
hover:scale-110 transition-transform duration-150"
style={{ width: 48, height: 48 }}
onClick={() => setOpen(true)}
type="button"
>
<DiscordIcon width={32} height={32} className="text-white" />
</button>
)}
{/* discord widget expand layer */}
{open && (
<div
ref={widgetRef}
className="fixed bottom-[84px] right-10 z-50 flex flex-col items-end"
style={{ width: widgetWidth, height: widgetHeight }}
>
<div className="rounded-lg overflow-hidden shadow-2xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900">
<WidgetBot
server={serverId}
channel={channelId}
width={widgetWidth}
height={widgetHeight}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -1,368 +0,0 @@
'use client';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { useCopyToClipboard } from '@/hooks/use-clipboard';
import { isUrlCached } from '@/lib/serviceWorker';
import { cn } from '@/lib/utils';
import * as RadioGroup from '@radix-ui/react-radio-group';
import { Check, Code2, Copy, Eye, Maximize, Terminal } from 'lucide-react';
import Link from 'next/link';
import type React from 'react';
import { useEffect, useRef, useState } from 'react';
import {
Panel,
PanelGroup,
PanelResizeHandle,
type ImperativePanelGroupHandle,
} from 'react-resizable-panels';
import { useMedia } from 'use-media';
export interface BlockPreviewProps {
code?: string;
preview: string;
title: string;
category: string;
previewOnly?: boolean;
}
const radioItem =
'rounded-(--radius) duration-200 flex items-center justify-center h-8 px-2.5 gap-2 transition-[color] data-[state=checked]:bg-muted';
const DEFAULTSIZE = 100;
const SMSIZE = 30;
const MDSIZE = 62;
const LGSIZE = 82;
const getCacheKey = (src: string) => `iframe-cache-${src}`;
const titleToNumber = (title: string): number => {
const titles = ["one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty"];
return titles.indexOf(title.toLowerCase()) + 1;
};
export const BlockPreview: React.FC<BlockPreviewProps> = ({
code,
preview,
title,
category,
previewOnly,
}) => {
const [width, setWidth] = useState(DEFAULTSIZE);
const [mode, setMode] = useState<'preview' | 'code'>('preview');
const [iframeHeight, setIframeHeight] = useState(0);
const [shouldLoadIframe, setShouldLoadIframe] = useState(false);
const [cachedHeight, setCachedHeight] = useState<number | null>(null);
const [isIframeCached, setIsIframeCached] = useState(false);
const terminalCode = `pnpm dlx shadcn@canary add https://nsui.irung.me/r/${category}-${titleToNumber(title)}.json`;
const { copied, copy } = useCopyToClipboard({ code: code as string, title, category, eventName: 'block_copy' })
const { copied: cliCopied, copy: cliCopy } = useCopyToClipboard({ code: terminalCode, title, category, eventName: 'block_cli_copy' })
const ref = useRef<ImperativePanelGroupHandle>(null);
const isLarge = useMedia('(min-width: 1024px)');
const iframeRef = useRef<HTMLIFrameElement>(null);
const observer = useRef<IntersectionObserver | null>(null);
const blockRef = useRef<HTMLDivElement>(null);
useEffect(() => {
observer.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setShouldLoadIframe(true);
observer.current?.disconnect();
}
},
{ threshold: 0.1 }
);
if (blockRef.current) {
observer.current.observe(blockRef.current);
}
return () => {
observer.current?.disconnect();
};
}, []);
useEffect(() => {
const checkCache = async () => {
try {
const isCached = await isUrlCached(preview);
setIsIframeCached(isCached);
if (isCached) {
setShouldLoadIframe(true);
}
} catch (error) {
console.error('Error checking cache status:', error);
}
};
checkCache();
try {
const cacheKey = getCacheKey(preview);
const cached = localStorage.getItem(cacheKey);
if (cached) {
const { height, timestamp } = JSON.parse(cached);
const now = Date.now();
if (now - timestamp < 24 * 60 * 60 * 1000) {
setCachedHeight(height);
setIframeHeight(height);
}
}
} catch (error) {
console.error('Error retrieving cache:', error);
}
}, [preview]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || !shouldLoadIframe) return;
const handleLoad = () => {
try {
const contentHeight = iframe.contentWindow!.document.body.scrollHeight;
setIframeHeight(contentHeight);
const cacheKey = getCacheKey(preview);
const cacheValue = JSON.stringify({
height: contentHeight,
timestamp: Date.now(),
});
localStorage.setItem(cacheKey, cacheValue);
} catch (e) {
console.error('Error accessing iframe content:', e);
}
};
iframe.addEventListener('load', handleLoad);
return () => {
iframe.removeEventListener('load', handleLoad);
};
}, [shouldLoadIframe, preview]);
useEffect(() => {
if (!blockRef.current || shouldLoadIframe) return;
const linkElement = document.createElement('link');
linkElement.rel = 'preload';
linkElement.href = preview;
linkElement.as = 'document';
if (
!document.head.querySelector(`link[rel="preload"][href="${preview}"]`)
) {
document.head.appendChild(linkElement);
}
return () => {
const existingLink = document.head.querySelector(
`link[rel="preload"][href="${preview}"]`
);
if (existingLink) {
document.head.removeChild(existingLink);
}
};
}, [preview, shouldLoadIframe]);
return (
<section className="group mb-16 border-b [--color-border:color-mix(in_oklab,var(--color-zinc-200)_75%,transparent)] dark:[--color-border:color-mix(in_oklab,var(--color-zinc-800)_60%,transparent)]">
<div className="relative border-y">
<div
aria-hidden
className="absolute inset-x-4 -top-14 bottom-0 mx-auto max-w-7xl lg:inset-x-0"
>
<div className="to-(--color-border) absolute bottom-0 left-0 top-0 w-px bg-gradient-to-b from-transparent to-75%"></div>
<div className="to-(--color-border) absolute bottom-0 right-0 top-0 w-px bg-gradient-to-b from-transparent to-75%"></div>
</div>
<div className="relative z-10 mx-auto flex max-w-7xl justify-between py-1.5 pl-8 pr-6 [--color-border:var(--color-zinc-200)] md:py-2 lg:pl-6 lg:pr-2 dark:[--color-border:var(--color-zinc-800)]">
<div className="-ml-3 flex items-center gap-3">
{code && (
<>
<RadioGroup.Root className="flex gap-0.5">
<RadioGroup.Item
onClick={() => setMode('preview')}
aria-label="Block preview"
value="100"
checked={mode == 'preview'}
className={radioItem}
>
<Eye className="size-3.5 sm:opacity-50" />
<span className="hidden text-[13px] sm:block">Preview</span>
</RadioGroup.Item>
<RadioGroup.Item
onClick={() => setMode('code')}
aria-label="Code"
value="0"
checked={mode == 'code'}
className={radioItem}
>
<Code2 className="size-3.5 sm:opacity-50" />
<span className="hidden text-[13px] sm:block">Code</span>
</RadioGroup.Item>
</RadioGroup.Root>
<Separator
orientation="vertical"
className="hidden !h-4 lg:block"
/>
</>
)}
{previewOnly && (
<>
{' '}
<span className="ml-2 text-sm capitalize">{title}</span>
<Separator orientation="vertical" className="!h-4" />{' '}
</>
)}
{/* <Button asChild variant="ghost" size="sm" className="size-8">
<Link href={preview} passHref target="_blank">
<Maximize className="size-4" />
</Link>
</Button> */}
<Separator
orientation="vertical"
className="hidden !h-4 lg:block"
/>
<span className="text-muted-foreground hidden text-sm lg:block">
{width < MDSIZE
? 'Mobile'
: width < LGSIZE
? 'Tablet'
: 'Desktop'}
</span>{' '}
</div>
<div className="flex items-center gap-2">
{code && (
<>
<Button
onClick={cliCopy}
size="sm"
className="size-8 shadow-none md:w-fit"
variant="outline"
aria-label="copy code">
{cliCopied ? <Check className="size-4" /> : <Terminal className="!size-3.5" />}
<span className="hidden font-mono text-xs md:block">
pnpm dlx shadcn@canary add {category}-{titleToNumber(title)}
</span>
</Button>
<Separator className="!h-4" orientation="vertical" />
{/* <OpenInV0Button
{...{ title, category }}
block={`${category}-${titleToNumber(title)}`}
/> */}
<Separator className="!h-4" orientation="vertical" />
<Button
onClick={copy}
size="sm"
variant="ghost"
aria-label="copy code"
className="size-8">
{copied ? <Check className="size-4" /> : <Copy className="!size-3.5" />}
</Button>
</>
)}
{!code && (
<span className="hidden font-mono text-sm md:block">
{/* pnpm dlx shadcn@canary add */}{category}-{titleToNumber(title)}
</span>
)}
</div>
</div>
</div>
<div className="relative">
<div
aria-hidden
className="absolute inset-x-4 -bottom-14 mx-auto h-14 max-w-7xl lg:inset-x-0"
>
<div className="from-(--color-border) absolute bottom-0 left-0 top-0 w-px bg-gradient-to-b"></div>
<div className="from-(--color-border) absolute bottom-0 right-0 top-0 w-px bg-gradient-to-b"></div>
</div>
<div className="relative z-10 mx-auto max-w-7xl px-4 lg:border-r lg:px-0">
<div
className={cn(
'bg-white dark:bg-transparent',
mode == 'code' && 'hidden'
)}
>
<PanelGroup direction="horizontal" tagName="div" ref={ref}>
<Panel
id={`block-${title}`}
order={1}
onResize={(size) => {
setWidth(Number(size));
}}
defaultSize={DEFAULTSIZE}
minSize={SMSIZE}
className="h-fit border-x"
>
<div ref={blockRef}>
{shouldLoadIframe ? (
<iframe
key={`${category}-${title}-iframe`}
loading={isIframeCached ? 'eager' : 'lazy'}
allowFullScreen
ref={iframeRef}
title={title}
height={cachedHeight || iframeHeight}
className={cn(
'h-(--iframe-height) block min-h-56 w-full duration-200 will-change-auto',
!cachedHeight &&
'@starting:opacity-0 @starting:blur-xl',
isIframeCached && '!opacity-100 !blur-none'
)}
src={preview}
id={`block-${title}`}
style={
{
'--iframe-height': `${cachedHeight || iframeHeight}px`,
display: 'block',
} as React.CSSProperties
}
/>
) : (
<div className="flex min-h-56 items-center justify-center">
<div className="border-primary size-6 animate-spin rounded-full border-2 border-t-transparent" />
</div>
)}
</div>
</Panel>
{isLarge && (
<>
<PanelResizeHandle className="relative w-2 before:absolute before:inset-0 before:m-auto before:h-12 before:w-1 before:rounded-full before:bg-zinc-300 before:transition-[height,background] hover:before:h-16 hover:before:bg-zinc-400 focus:before:bg-zinc-400 dark:before:bg-zinc-600 dark:hover:before:bg-zinc-500 dark:focus:before:bg-zinc-400" />
<Panel
id={`code-${title}`}
order={2}
defaultSize={100 - DEFAULTSIZE}
className="-mr-[0.5px] ml-px"
></Panel>
</>
)}
</PanelGroup>
</div>
<div className="bg-white dark:bg-transparent">
{/* {mode == 'code' && (
<CodeBlock
code={code as string}
lang="tsx"
maxHeight={iframeHeight}
/>
)} */}
</div>
</div>
</div>
</section>
);
};
export default BlockPreview;

View File

@ -6,19 +6,27 @@ import { CoinsIcon } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export function CreditsTest() { const CONSUME_CREDITS = 10;
const { data: balance = 0, isLoading } = useCreditBalance();
export function ConsumeCreditsCard() {
const { data: balance = 0, isLoading: isLoadingBalance } = useCreditBalance();
const consumeCreditsMutation = useConsumeCredits(); const consumeCreditsMutation = useConsumeCredits();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const hasEnoughCredits = (amount: number) => balance >= amount;
const handleConsume = async () => { const handleConsume = async () => {
if (!hasEnoughCredits(CONSUME_CREDITS)) {
toast.error('Insufficient credits, please buy more credits.');
return;
}
setLoading(true); setLoading(true);
try { try {
await consumeCreditsMutation.mutateAsync({ await consumeCreditsMutation.mutateAsync({
amount: 10, amount: CONSUME_CREDITS,
description: 'Test credit consumption', description: `Test credit consumption (${CONSUME_CREDITS} credits)`,
}); });
toast.success('10 credits consumed successfully!'); toast.success(`${CONSUME_CREDITS} credits consumed successfully!`);
} catch (error) { } catch (error) {
toast.error('Failed to consume credits'); toast.error('Failed to consume credits');
} finally { } finally {
@ -39,11 +47,13 @@ export function CreditsTest() {
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleConsume} onClick={handleConsume}
disabled={loading || consumeCreditsMutation.isPending} disabled={
loading || consumeCreditsMutation.isPending || isLoadingBalance
}
size="sm" size="sm"
> >
<CoinsIcon className="w-4 h-4 mr-2" /> <CoinsIcon className="w-4 h-4 mr-2" />
Consume 10 Credits Consume {CONSUME_CREDITS} Credits
</Button> </Button>
</div> </div>

View File

@ -8,15 +8,17 @@ import type { WebsiteConfig } from '@/types';
* https://mksaas.com/docs/config/website * https://mksaas.com/docs/config/website
*/ */
export const websiteConfig: WebsiteConfig = { export const websiteConfig: WebsiteConfig = {
metadata: { ui: {
theme: { theme: {
defaultTheme: 'default', defaultTheme: 'default',
enableSwitch: true, enableSwitch: true,
}, },
mode: { mode: {
defaultMode: 'system', defaultMode: 'dark',
enableSwitch: true, enableSwitch: true,
}, },
},
metadata: {
images: { images: {
ogImage: '/og.png', ogImage: '/og.png',
logoLight: '/logo.png', logoLight: '/logo.png',
@ -33,7 +35,6 @@ export const websiteConfig: WebsiteConfig = {
}, },
}, },
features: { features: {
enableDiscordWidget: false,
enableUpgradeCard: true, enableUpgradeCard: true,
enableUpdateAvatar: true, enableUpdateAvatar: true,
enableAffonsoAffiliate: false, enableAffonsoAffiliate: false,

View File

@ -2,10 +2,8 @@ import { consumeCreditsAction } from '@/actions/consume-credits';
import { getCreditBalanceAction } from '@/actions/get-credit-balance'; import { getCreditBalanceAction } from '@/actions/get-credit-balance';
import { getCreditStatsAction } from '@/actions/get-credit-stats'; import { getCreditStatsAction } from '@/actions/get-credit-stats';
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions'; import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
import { useCreditsStore } from '@/stores/credits-store';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { SortingState } from '@tanstack/react-table'; import type { SortingState } from '@tanstack/react-table';
import { useEffect } from 'react';
// Query keys // Query keys
export const creditsKeys = { export const creditsKeys = {
@ -23,9 +21,7 @@ export const creditsKeys = {
// Hook to fetch credit balance // Hook to fetch credit balance
export function useCreditBalance() { export function useCreditBalance() {
const updateTrigger = useCreditsStore((state) => state.updateTrigger); return useQuery({
const query = useQuery({
queryKey: creditsKeys.balance(), queryKey: creditsKeys.balance(),
queryFn: async () => { queryFn: async () => {
console.log('Fetching credit balance...'); console.log('Fetching credit balance...');
@ -39,23 +35,11 @@ export function useCreditBalance() {
return result.data.credits || 0; return result.data.credits || 0;
}, },
}); });
// Refetch when updateTrigger changes
useEffect(() => {
if (updateTrigger > 0) {
console.log('Credits update triggered, refetching balance...');
query.refetch();
}
}, [updateTrigger, query]);
return query;
} }
// Hook to fetch credit statistics // Hook to fetch credit statistics
export function useCreditStats() { export function useCreditStats() {
const updateTrigger = useCreditsStore((state) => state.updateTrigger); return useQuery({
const query = useQuery({
queryKey: creditsKeys.stats(), queryKey: creditsKeys.stats(),
queryFn: async () => { queryFn: async () => {
console.log('Fetching credit stats...'); console.log('Fetching credit stats...');
@ -67,22 +51,11 @@ export function useCreditStats() {
return result.data.data; return result.data.data;
}, },
}); });
// Refetch when updateTrigger changes
useEffect(() => {
if (updateTrigger > 0) {
console.log('Credits update triggered, refetching stats...');
query.refetch();
}
}, [updateTrigger, query]);
return query;
} }
// Hook to consume credits // Hook to consume credits
export function useConsumeCredits() { export function useConsumeCredits() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const triggerUpdate = useCreditsStore((state) => state.triggerUpdate);
return useMutation({ return useMutation({
mutationFn: async ({ mutationFn: async ({
@ -102,9 +75,6 @@ export function useConsumeCredits() {
return result.data; return result.data;
}, },
onSuccess: () => { onSuccess: () => {
// Trigger credits update in store to notify all components
triggerUpdate();
// Invalidate credit balance and stats after consuming credits // Invalidate credit balance and stats after consuming credits
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: creditsKeys.balance(), queryKey: creditsKeys.balance(),

View File

@ -1,68 +0,0 @@
/**
* Service worker registration and management utilities
*/
// Register the service worker
export function registerServiceWorker() {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker
.register('/sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
})
.catch((registrationError) => {
console.log('SW registration failed: ', registrationError);
});
});
}
}
// Send a message to the service worker
type SWMessage = {
type: string;
url?: string;
};
export function sendMessageToSW(message: SWMessage) {
if (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
navigator.serviceWorker.controller
) {
navigator.serviceWorker.controller.postMessage(message);
}
}
// Clear iframe cache for a specific URL or all iframe caches if no URL provided
export function clearIframeCache(url?: string) {
sendMessageToSW({
type: 'CLEAR_IFRAME_CACHE',
url,
});
}
// Update the service worker
export function updateServiceWorker() {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => {
registration.update();
});
}
}
// Check if a URL is already cached by the service worker
export async function isUrlCached(url: string): Promise<boolean> {
if (typeof window === 'undefined' || !('caches' in window)) {
return false;
}
try {
const cache = await caches.open('cnblocks-iframe-cache-v1');
const cachedResponse = await cache.match(url);
return cachedResponse !== undefined;
} catch (error) {
console.error('Error checking cache:', error);
return false;
}
}

View File

@ -1,23 +0,0 @@
import { create } from 'zustand';
interface CreditsState {
// trigger for credit updates, incremented each time credits change
updateTrigger: number;
// method to trigger credit updates
triggerUpdate: () => void;
}
/**
* Credits store for managing credit balance updates.
*
* This store provides a simple trigger mechanism to notify components
* when credits have been consumed or updated, ensuring UI components can
* refetch the latest credit balance.
*/
export const useCreditsStore = create<CreditsState>((set) => ({
updateTrigger: 0,
triggerUpdate: () =>
set((state) => ({
updateTrigger: state.updateTrigger + 1,
})),
}));

12
src/types/index.d.ts vendored
View File

@ -6,6 +6,7 @@ import type { CreditPackage } from '@/credits/types';
* website config, without translations * website config, without translations
*/ */
export type WebsiteConfig = { export type WebsiteConfig = {
ui: UiConfig;
metadata: MetadataConfig; metadata: MetadataConfig;
features: FeaturesConfig; features: FeaturesConfig;
routes: RoutesConfig; routes: RoutesConfig;
@ -22,12 +23,18 @@ export type WebsiteConfig = {
credits: CreditsConfig; credits: CreditsConfig;
}; };
/**
* UI configuration
*/
export interface UiConfig {
mode?: ModeConfig;
theme?: ThemeConfig;
}
/** /**
* Website metadata * Website metadata
*/ */
export interface MetadataConfig { export interface MetadataConfig {
mode?: ModeConfig;
theme?: ThemeConfig;
images?: ImagesConfig; images?: ImagesConfig;
social?: SocialConfig; social?: SocialConfig;
} }
@ -69,7 +76,6 @@ export interface SocialConfig {
* Website features * Website features
*/ */
export interface FeaturesConfig { export interface FeaturesConfig {
enableDiscordWidget?: boolean; // Whether to enable the discord widget, deprecated
enableCrispChat?: boolean; // Whether to enable the crisp chat enableCrispChat?: boolean; // Whether to enable the crisp chat
enableUpgradeCard?: boolean; // Whether to enable the upgrade card in the sidebar enableUpgradeCard?: boolean; // Whether to enable the upgrade card in the sidebar
enableUpdateAvatar?: boolean; // Whether to enable the update avatar in settings enableUpdateAvatar?: boolean; // Whether to enable the update avatar in settings

View File

@ -1,24 +0,0 @@
/**
* ObjectValues<T> is a utility type that extracts a union type of all value types in an object
*
* For example, in the AppInfo use case:
* const AppInfo = {
* APP_NAME: string,
* APP_DESCRIPTION: string,
* PRODUCTION: boolean,
* VERSION: string
* } as const
*
* type AppInfo = ObjectValues<typeof AppInfo>
* equals to: type AppInfo = string | boolean
*
* How it works:
* 1. keyof T gets the union of all keys in object T
* 2. T[keyof T] uses indexed access to get all value types
*
* Benefits:
* - Automatically extracts all possible value types from an object
* - Makes type definitions more precise and automated
* - Reduces manual type maintenance work
*/
export type ObjectValues<T> = T[keyof T];