Merge remote-tracking branch 'origin/main' into cloudflare
This commit is contained in:
commit
613bbd0d78
@ -1,4 +1,6 @@
|
||||
.cursor
|
||||
.claude
|
||||
.kiro
|
||||
.github
|
||||
.next
|
||||
.open-next
|
||||
|
@ -21,7 +21,7 @@ If you found anything that could be improved, please let me know.
|
||||
- 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs)
|
||||
- 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap)
|
||||
- 👨💻 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
|
||||
|
||||
|
10
biome.json
10
biome.json
@ -12,6 +12,8 @@
|
||||
".open-next/**",
|
||||
".wrangler/**",
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".kiro/**",
|
||||
".vscode/**",
|
||||
".source/**",
|
||||
"node_modules/**",
|
||||
@ -27,8 +29,7 @@
|
||||
"src/app/[[]locale]/preview/**",
|
||||
"src/payment/types.ts",
|
||||
"src/credits/types.ts",
|
||||
"src/types/index.d.ts",
|
||||
"public/sw.js"
|
||||
"src/types/index.d.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
@ -75,6 +76,8 @@
|
||||
".open-next/**",
|
||||
".wrangler/**",
|
||||
".cursor/**",
|
||||
".claude/**",
|
||||
".kiro/**",
|
||||
".vscode/**",
|
||||
".source/**",
|
||||
"node_modules/**",
|
||||
@ -90,8 +93,7 @@
|
||||
"src/app/[[]locale]/preview/**",
|
||||
"src/payment/types.ts",
|
||||
"src/credits/types.ts",
|
||||
"src/types/index.d.ts",
|
||||
"public/sw.js"
|
||||
"src/types/index.d.ts"
|
||||
]
|
||||
},
|
||||
"javascript": {
|
||||
|
@ -87,7 +87,6 @@
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@vercel/analytics": "^1.5.0",
|
||||
"@vercel/speed-insights": "^1.2.0",
|
||||
"@widgetbot/react-embed": "^1.9.0",
|
||||
"ai": "^5.0.0",
|
||||
"better-auth": "^1.1.19",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
|
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
@ -191,9 +191,6 @@ importers:
|
||||
'@vercel/speed-insights':
|
||||
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)
|
||||
'@widgetbot/react-embed':
|
||||
specifier: ^1.9.0
|
||||
version: 1.9.0(react@19.0.0)
|
||||
ai:
|
||||
specifier: ^5.0.0
|
||||
version: 5.0.0(zod@4.0.17)
|
||||
@ -4824,14 +4821,6 @@ packages:
|
||||
vue-router:
|
||||
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:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
@ -5157,12 +5146,6 @@ packages:
|
||||
crisp-sdk-web@1.0.25:
|
||||
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:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@ -6947,9 +6930,6 @@ packages:
|
||||
resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
post-robot@8.0.32:
|
||||
resolution: {integrity: sha512-PMOdDAt3pyuKUxZcTzdcXXFxLqkdeLpRlcCQl7QAJpI+e7J1YHH+PfC7KAbcL8hRVQ1LknQYGoirbA1/eO/a1g==}
|
||||
|
||||
postcss-selector-parser@7.1.0:
|
||||
resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==}
|
||||
engines: {node: '>=4'}
|
||||
@ -7918,9 +7898,6 @@ packages:
|
||||
youch@4.1.0-beta.10:
|
||||
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:
|
||||
resolution: {integrity: sha512-pNUqrcSxuuB3/+jBbU8qKUbTbDqYUaG1vf5cXFjbhGgoUuA1amO/y4Q8lzfOhHU8HNPK6VFJ18lBDKj3OHyDsg==}
|
||||
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)
|
||||
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:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
@ -13459,14 +13427,6 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@ -15667,12 +15627,6 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
@ -16851,8 +16805,6 @@ snapshots:
|
||||
cookie: 1.0.2
|
||||
youch-core: 0.3.3
|
||||
|
||||
zalgo-promise@1.0.48: {}
|
||||
|
||||
zod-to-json-schema@3.24.2(zod@3.25.64):
|
||||
dependencies:
|
||||
zod: 3.25.64
|
||||
|
129
public/sw.js
129
public/sw.js
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
@ -70,7 +70,7 @@ export default function ChatBot() {
|
||||
};
|
||||
|
||||
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">
|
||||
<Conversation className="h-full">
|
||||
<ConversationContent>
|
||||
|
@ -76,9 +76,9 @@ export function ImagePlayground({
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<ImageGeneratorHeader />
|
||||
{/* <ImageGeneratorHeader /> */}
|
||||
|
||||
{/* input prompt */}
|
||||
<PromptInput
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -34,7 +34,6 @@ interface ErrorDisplayProps {
|
||||
const errorIcons = {
|
||||
[ErrorType.VALIDATION]: AlertCircleIcon,
|
||||
[ErrorType.NETWORK]: WifiOffIcon,
|
||||
[ErrorType.CREDITS]: CreditCardIcon,
|
||||
[ErrorType.SCRAPING]: ServerIcon,
|
||||
[ErrorType.ANALYSIS]: HelpCircleIcon,
|
||||
[ErrorType.TIMEOUT]: ClockIcon,
|
||||
@ -84,7 +83,6 @@ const severityColors = {
|
||||
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',
|
||||
|
@ -1,5 +1,4 @@
|
||||
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';
|
||||
|
@ -1,9 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { checkWebContentAnalysisCreditsAction } from '@/actions/check-web-content-analysis-credits';
|
||||
import type { UrlInputFormProps } 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,
|
||||
@ -20,21 +18,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
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 { LinkIcon, Loader2Icon, SparklesIcon } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
import { useDebounce } from '../utils/performance';
|
||||
|
||||
@ -52,19 +39,9 @@ export function UrlInputForm({
|
||||
modelProvider,
|
||||
setModelProvider,
|
||||
}: 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
|
||||
// Prevent hydration mismatch by only rendering content after mount
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
@ -84,42 +61,6 @@ export function UrlInputForm({
|
||||
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) {
|
||||
@ -129,23 +70,12 @@ export function UrlInputForm({
|
||||
}, [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 ?? '', modelProvider);
|
||||
};
|
||||
|
||||
const handleFormSubmit = form.handleSubmit(handleSubmit);
|
||||
|
||||
const isInsufficientCredits = creditInfo && !creditInfo.hasEnoughCredits;
|
||||
const isFormDisabled = isLoading || disabled || !!isInsufficientCredits;
|
||||
const isFormDisabled = isLoading || disabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -161,10 +91,10 @@ export function UrlInputForm({
|
||||
<SelectValue placeholder="Select model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
||||
<SelectItem value="openai">OpenAI GPT-4o</SelectItem>
|
||||
<SelectItem value="gemini">Google Gemini</SelectItem>
|
||||
<SelectItem value="deepseek">DeepSeek</SelectItem>
|
||||
<SelectItem value="openrouter">OpenRouter</SelectItem>
|
||||
<SelectItem value="deepseek">DeepSeek R1</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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 ? (
|
||||
// Show loading state during hydration to prevent mismatch
|
||||
<Button type="button" disabled className="w-full" size="lg">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</Button>
|
||||
) : isAuthenticated ? (
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isFormDisabled || !urlValue?.trim()}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
>
|
||||
{isAuthLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Loading...</span>
|
||||
</>
|
||||
) : isCheckingCredits ? (
|
||||
<>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Checking Credits...</span>
|
||||
</>
|
||||
) : isLoading ? (
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span>Analyzing...</span>
|
||||
@ -262,24 +145,10 @@ export function UrlInputForm({
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon className="size-4" />
|
||||
<span>
|
||||
Analyze Website
|
||||
{creditInfo && ` (${creditInfo.requiredCredits} credits)`}
|
||||
</span>
|
||||
<span>Analyze Website</span>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
|
@ -194,7 +194,8 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
|
||||
const [state, dispatch] = useReducer(analysisReducer, initialState);
|
||||
|
||||
// Model provider state
|
||||
const [modelProvider, setModelProvider] = useState<ModelProvider>('openai');
|
||||
const [modelProvider, setModelProvider] =
|
||||
useState<ModelProvider>('openrouter');
|
||||
|
||||
// Enhanced error state
|
||||
const [analyzedError, setAnalyzedError] =
|
||||
@ -232,16 +233,6 @@ export function WebContentAnalyzer({ className }: WebContentAnalyzerProps) {
|
||||
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;
|
||||
|
@ -9,7 +9,6 @@ import { webContentAnalyzerConfig } from '@/ai/text/utils/web-content-analyzer-c
|
||||
export enum ErrorType {
|
||||
VALIDATION = 'validation',
|
||||
NETWORK = 'network',
|
||||
CREDITS = 'credits',
|
||||
SCRAPING = 'scraping',
|
||||
ANALYSIS = 'analysis',
|
||||
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
|
||||
if (
|
||||
message.includes('scrape') ||
|
||||
@ -278,16 +261,6 @@ export function getRecoveryActions(error: WebContentAnalyzerError): Array<{
|
||||
{ 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 },
|
||||
|
@ -6,11 +6,6 @@
|
||||
*/
|
||||
|
||||
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
|
||||
@ -118,21 +113,15 @@ export const webContentAnalyzerConfig = {
|
||||
maxTokens: 2000,
|
||||
},
|
||||
openrouter: {
|
||||
model: 'openrouter/horizon-beta',
|
||||
// model: 'openrouter/horizon-beta',
|
||||
// model: 'x-ai/grok-3-beta',
|
||||
// model: 'openai/gpt-4o-mini',
|
||||
model: 'deepseek/deepseek-r1:free',
|
||||
temperature: 0.1,
|
||||
maxTokens: 2000,
|
||||
},
|
||||
} 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
|
||||
*/
|
||||
@ -151,8 +140,6 @@ export function validateFirecrawlConfig(): boolean {
|
||||
*/
|
||||
export function validateWebContentAnalyzerConfig(): boolean {
|
||||
return (
|
||||
typeof webContentAnalyzerConfig.creditsCost === 'number' &&
|
||||
webContentAnalyzerConfig.creditsCost > 0 &&
|
||||
typeof webContentAnalyzerConfig.maxContentLength === 'number' &&
|
||||
webContentAnalyzerConfig.maxContentLength > 0 &&
|
||||
typeof webContentAnalyzerConfig.timeoutMillis === 'number' &&
|
||||
|
@ -67,7 +67,7 @@ export interface AnalysisState {
|
||||
}
|
||||
|
||||
// Component Props Interfaces
|
||||
export type ModelProvider = 'openai' | 'gemini' | 'deepseek';
|
||||
export type ModelProvider = 'openai' | 'gemini' | 'deepseek' | 'openrouter';
|
||||
|
||||
export interface WebContentAnalyzerProps {
|
||||
className?: string;
|
||||
|
@ -1,12 +1,12 @@
|
||||
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() {
|
||||
return (
|
||||
<Container className="py-16 px-4">
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* credits test */}
|
||||
<CreditsTest />
|
||||
<ConsumeCreditsCard />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
@ -37,7 +37,9 @@ export default async function AIChatPage() {
|
||||
</div>
|
||||
|
||||
{/* Chat Bot */}
|
||||
<ChatBot />
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<ChatBot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -2,6 +2,7 @@ import { ImagePlayground } from '@/ai/image/components/ImagePlayground';
|
||||
import { getRandomSuggestions } from '@/ai/image/lib/suggestions';
|
||||
import { constructMetadata } from '@/lib/metadata';
|
||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||
import { ImageIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import type { Locale } from 'next-intl';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
@ -26,8 +27,21 @@ export default async function AIImagePage() {
|
||||
const t = await getTranslations('AIImagePage');
|
||||
|
||||
return (
|
||||
<div className="mx-auto space-y-8">
|
||||
<ImagePlayground suggestions={getRandomSuggestions(5)} />
|
||||
<div className="min-h-screen bg-muted/50 rounded-lg">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
@ -29,7 +29,7 @@ interface ProvidersProps {
|
||||
*/
|
||||
export function Providers({ children, locale }: ProvidersProps) {
|
||||
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
|
||||
// make sure `locale` is consistent with your i18n config
|
||||
|
@ -13,12 +13,9 @@ import {
|
||||
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 { createDeepSeek } from '@ai-sdk/deepseek';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
@ -30,7 +27,6 @@ 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
|
||||
@ -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
|
||||
if (!validateFirecrawlConfig()) {
|
||||
const configError = new WebContentAnalyzerError(
|
||||
@ -404,39 +378,7 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 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}]`
|
||||
);
|
||||
console.log(`Starting analysis [requestId=${requestId}, url=${url}]`);
|
||||
|
||||
// Perform analysis with timeout and enhanced error handling
|
||||
const analysisPromise = (async () => {
|
||||
@ -447,13 +389,6 @@ export async function POST(req: NextRequest) {
|
||||
// Step 2: Analyze content with AI (pass provider)
|
||||
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 };
|
||||
} catch (error) {
|
||||
// If it's already a WebContentAnalyzerError, just re-throw
|
||||
@ -477,7 +412,6 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
creditsConsumed: CREDITS_COST,
|
||||
} satisfies AnalyzeContentResponse);
|
||||
} catch (error) {
|
||||
const elapsed = ((performance.now() - startTime) / 1000).toFixed(1);
|
||||
@ -499,12 +433,6 @@ export async function POST(req: NextRequest) {
|
||||
case ErrorType.VALIDATION:
|
||||
statusCode = 400;
|
||||
break;
|
||||
case ErrorType.AUTHENTICATION:
|
||||
statusCode = 401;
|
||||
break;
|
||||
case ErrorType.CREDITS:
|
||||
statusCode = 402;
|
||||
break;
|
||||
case ErrorType.TIMEOUT:
|
||||
statusCode = 408;
|
||||
break;
|
||||
|
@ -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 handleSignOut = async () => {
|
||||
|
@ -10,7 +10,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
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) {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
@ -12,7 +12,7 @@ import { useEffect, useState } from 'react';
|
||||
* Mode switcher component, used in the footer
|
||||
*/
|
||||
export function ModeSwitcherHorizontal() {
|
||||
if (!websiteConfig.metadata.mode?.enableSwitch) {
|
||||
if (!websiteConfig.ui.mode?.enableSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ import { useTheme } from 'next-themes';
|
||||
* Mode switcher component, used in the navbar
|
||||
*/
|
||||
export function ModeSwitcher() {
|
||||
if (!websiteConfig.metadata.mode?.enableSwitch) {
|
||||
if (!websiteConfig.ui.mode?.enableSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -21,7 +21,7 @@ import { useThemeConfig } from './active-theme-provider';
|
||||
* https://github.com/TheOrcDev/orcish-dashboard/blob/main/components/theme-selector.tsx
|
||||
*/
|
||||
export function ThemeSelector() {
|
||||
if (!websiteConfig.metadata.theme?.enableSwitch) {
|
||||
if (!websiteConfig.ui.theme?.enableSwitch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
@ -6,19 +6,27 @@ import { CoinsIcon } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function CreditsTest() {
|
||||
const { data: balance = 0, isLoading } = useCreditBalance();
|
||||
const CONSUME_CREDITS = 10;
|
||||
|
||||
export function ConsumeCreditsCard() {
|
||||
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: 10,
|
||||
description: 'Test credit consumption',
|
||||
amount: CONSUME_CREDITS,
|
||||
description: `Test credit consumption (${CONSUME_CREDITS} credits)`,
|
||||
});
|
||||
toast.success('10 credits consumed successfully!');
|
||||
toast.success(`${CONSUME_CREDITS} credits consumed successfully!`);
|
||||
} catch (error) {
|
||||
toast.error('Failed to consume credits');
|
||||
} finally {
|
||||
@ -39,11 +47,13 @@ export function CreditsTest() {
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleConsume}
|
||||
disabled={loading || consumeCreditsMutation.isPending}
|
||||
disabled={
|
||||
loading || consumeCreditsMutation.isPending || isLoadingBalance
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
<CoinsIcon className="w-4 h-4 mr-2" />
|
||||
Consume 10 Credits
|
||||
Consume {CONSUME_CREDITS} Credits
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -8,15 +8,17 @@ import type { WebsiteConfig } from '@/types';
|
||||
* https://mksaas.com/docs/config/website
|
||||
*/
|
||||
export const websiteConfig: WebsiteConfig = {
|
||||
metadata: {
|
||||
ui: {
|
||||
theme: {
|
||||
defaultTheme: 'default',
|
||||
enableSwitch: true,
|
||||
},
|
||||
mode: {
|
||||
defaultMode: 'system',
|
||||
defaultMode: 'dark',
|
||||
enableSwitch: true,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
images: {
|
||||
ogImage: '/og.png',
|
||||
logoLight: '/logo.png',
|
||||
@ -33,7 +35,6 @@ export const websiteConfig: WebsiteConfig = {
|
||||
},
|
||||
},
|
||||
features: {
|
||||
enableDiscordWidget: false,
|
||||
enableUpgradeCard: true,
|
||||
enableUpdateAvatar: true,
|
||||
enableAffonsoAffiliate: false,
|
||||
|
@ -2,10 +2,8 @@ import { consumeCreditsAction } from '@/actions/consume-credits';
|
||||
import { getCreditBalanceAction } from '@/actions/get-credit-balance';
|
||||
import { getCreditStatsAction } from '@/actions/get-credit-stats';
|
||||
import { getCreditTransactionsAction } from '@/actions/get-credit-transactions';
|
||||
import { useCreditsStore } from '@/stores/credits-store';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
// Query keys
|
||||
export const creditsKeys = {
|
||||
@ -23,9 +21,7 @@ export const creditsKeys = {
|
||||
|
||||
// Hook to fetch credit balance
|
||||
export function useCreditBalance() {
|
||||
const updateTrigger = useCreditsStore((state) => state.updateTrigger);
|
||||
|
||||
const query = useQuery({
|
||||
return useQuery({
|
||||
queryKey: creditsKeys.balance(),
|
||||
queryFn: async () => {
|
||||
console.log('Fetching credit balance...');
|
||||
@ -39,23 +35,11 @@ export function useCreditBalance() {
|
||||
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
|
||||
export function useCreditStats() {
|
||||
const updateTrigger = useCreditsStore((state) => state.updateTrigger);
|
||||
|
||||
const query = useQuery({
|
||||
return useQuery({
|
||||
queryKey: creditsKeys.stats(),
|
||||
queryFn: async () => {
|
||||
console.log('Fetching credit stats...');
|
||||
@ -67,22 +51,11 @@ export function useCreditStats() {
|
||||
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
|
||||
export function useConsumeCredits() {
|
||||
const queryClient = useQueryClient();
|
||||
const triggerUpdate = useCreditsStore((state) => state.triggerUpdate);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
@ -102,9 +75,6 @@ export function useConsumeCredits() {
|
||||
return result.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Trigger credits update in store to notify all components
|
||||
triggerUpdate();
|
||||
|
||||
// Invalidate credit balance and stats after consuming credits
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: creditsKeys.balance(),
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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
12
src/types/index.d.ts
vendored
@ -6,6 +6,7 @@ import type { CreditPackage } from '@/credits/types';
|
||||
* website config, without translations
|
||||
*/
|
||||
export type WebsiteConfig = {
|
||||
ui: UiConfig;
|
||||
metadata: MetadataConfig;
|
||||
features: FeaturesConfig;
|
||||
routes: RoutesConfig;
|
||||
@ -22,12 +23,18 @@ export type WebsiteConfig = {
|
||||
credits: CreditsConfig;
|
||||
};
|
||||
|
||||
/**
|
||||
* UI configuration
|
||||
*/
|
||||
export interface UiConfig {
|
||||
mode?: ModeConfig;
|
||||
theme?: ThemeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Website metadata
|
||||
*/
|
||||
export interface MetadataConfig {
|
||||
mode?: ModeConfig;
|
||||
theme?: ThemeConfig;
|
||||
images?: ImagesConfig;
|
||||
social?: SocialConfig;
|
||||
}
|
||||
@ -69,7 +76,6 @@ export interface SocialConfig {
|
||||
* Website features
|
||||
*/
|
||||
export interface FeaturesConfig {
|
||||
enableDiscordWidget?: boolean; // Whether to enable the discord widget, deprecated
|
||||
enableCrispChat?: boolean; // Whether to enable the crisp chat
|
||||
enableUpgradeCard?: boolean; // Whether to enable the upgrade card in the sidebar
|
||||
enableUpdateAvatar?: boolean; // Whether to enable the update avatar in settings
|
||||
|
@ -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];
|
Loading…
Reference in New Issue
Block a user