Merge remote-tracking branch 'origin/main' into cloudflare
This commit is contained in:
commit
613bbd0d78
@ -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
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
10
biome.json
10
biome.json
@ -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": {
|
||||||
|
@ -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
48
pnpm-lock.yaml
generated
@ -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
|
||||||
|
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 (
|
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>
|
||||||
|
@ -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
|
||||||
|
@ -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 = {
|
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',
|
||||||
|
@ -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';
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
|
@ -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 },
|
||||||
|
@ -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' &&
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
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
|
||||||
|
@ -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;
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 { 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>
|
||||||
|
|
@ -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,
|
||||||
|
@ -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(),
|
||||||
|
@ -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
|
* 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
|
||||||
|
@ -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