Compare commits
27 Commits
ee2bbc817f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac520dfcff | ||
|
|
65a37ad477 | ||
|
|
e8f62c18a6 | ||
|
|
9f2f235c09 | ||
|
|
0634c26138 | ||
|
|
e4630082e0 | ||
|
|
00d2bdd384 | ||
| 1b4cb0382d | |||
|
|
340a1c3e61 | ||
|
|
6281a0d467 | ||
| e23db74eb8 | |||
|
|
3e164fb687 | ||
|
|
1c6e06bd6d | ||
|
|
f6857712a8 | ||
|
|
22fade091d | ||
|
|
b8e20ab510 | ||
|
|
6071cd5228 | ||
| 98a17011a2 | |||
| 12d9936f17 | |||
| b066a53ffa | |||
| 36e3e2ed4a | |||
|
|
4e79dde03e | ||
|
|
9091219729 | ||
|
|
5a32c1a7d2 | ||
|
|
10a2c0c2c3 | ||
|
|
7258809230 | ||
|
|
5efa3d3270 |
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
public/robots.txt
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
@@ -24,3 +25,5 @@ pnpm-debug.log*
|
||||
.idea/
|
||||
|
||||
.vscode/
|
||||
|
||||
.env
|
||||
|
||||
@@ -3,13 +3,33 @@ import { defineConfig } from 'astro/config';
|
||||
import tailwind from '@astrojs/tailwind';
|
||||
import react from '@astrojs/react';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://siliconpin.com',
|
||||
site: 'https://sp-dev-h1-astgsbchdykdvtf.siliconpin.com',
|
||||
integrations: [tailwind(), react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000
|
||||
|
||||
// Vite-specific settings (including proxy)
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: ['sp-dev-h1-astgsbchdykdvtf.siliconpin.com'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080', // Your backend server
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
secure: false, // Only needed if using self-signed HTTPS
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
allowedHosts: ['sp-dev-h1-astgsbchdykdvtf.siliconpin.com'],
|
||||
}
|
||||
},
|
||||
|
||||
// Astro server settings
|
||||
server: {
|
||||
host: '0.0.0.0', // Accessible on all network interfaces
|
||||
port: 4000,
|
||||
},
|
||||
|
||||
output: 'static',
|
||||
});
|
||||
});
|
||||
24
env_sample.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
PUBLIC_USER_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/users/
|
||||
|
||||
PUBLIC_SERVICE_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/services/
|
||||
|
||||
PUBLIC_INVOICE_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/invoice/
|
||||
|
||||
PUBLIC_MINIO_UPLOAD_URL=https://hostapi2.cs1.hz.siliconpin.com/api/storage/upload
|
||||
|
||||
PUBLIC_HOST_API2_USERS=https://hostapi2.cs1.hz.siliconpin.com/api/users
|
||||
|
||||
PUBLIC_TOPIC_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/topics/
|
||||
|
||||
PUBLIC_TICKET_API_URL=https://host-api-sxashuasysagibx.siliconpin.com/v1/ticket/index.php
|
||||
|
||||
PUBLIC_HETZNER_API_KEY=uMSBR9nxdtbuvazsVM8YMMDd0PvuynpgJbmzFIO47HblMlh7tlHT8wV05sQ28Squ
|
||||
|
||||
PUBLIC_UTHO_API_KEY=IoNXhkRJsQPyOEqFMceSfzuKaDLrpxUCATgZjiVdvYlBHbwWmGtn
|
||||
|
||||
PUBLIC_DIGITALOCEAN_API_KEY=dop_v1_b6a075ece5786faf7c58d21761dbf95d47af372da062d68a870ce2a0bae51adf
|
||||
|
||||
PUBLIC_POCKETBASE_URL=https://tst-pb.s38.siliconpin.com
|
||||
|
||||
|
||||
ENV_TYPE=DEV
|
||||
59
fix-php-tags-and-decode.cjs
Normal file
@@ -0,0 +1,59 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const buildDir = path.join(__dirname, 'dist');
|
||||
|
||||
// Basic HTML entity decode
|
||||
function decodeHtmlEntities(str) {
|
||||
return str
|
||||
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(dec))
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// Decode both URI and HTML entities inside <?php ... ?>
|
||||
function decodePhpBlocks(content) {
|
||||
return content.replace(/<\?php([\s\S]*?)\?>/g, (match, code) => {
|
||||
try {
|
||||
let decoded = decodeURIComponent(code); // %XX to characters
|
||||
decoded = decodeHtmlEntities(decoded); // ' to '
|
||||
return `<?php${decoded}?>`;
|
||||
} catch (e) {
|
||||
console.warn('⚠️ Decode failed for block:', code.trim().slice(0, 50), '...');
|
||||
return match;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fixPhpTagsInFile(filePath) {
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
// Step 1: Replace escaped tags
|
||||
content = content
|
||||
.replace(/<\?php/g, '<?php')
|
||||
.replace(/\?>/g, '?>');
|
||||
|
||||
// Step 2: Decode content inside <?php ... ?>
|
||||
content = decodePhpBlocks(content);
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
console.log(`✅ Processed: ${filePath}`);
|
||||
}
|
||||
|
||||
function walkDir(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkDir(fullPath);
|
||||
} else if (fullPath.endsWith('.html') || fullPath.endsWith('.php')) {
|
||||
fixPhpTagsInFile(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkDir(buildDir);
|
||||
console.log('🎉 All PHP tag fixes, URL decodes, and HTML entity decodes complete.');
|
||||
7721
package-lock.json
generated
31
package.json
@@ -3,11 +3,12 @@
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev": "astro dev --port 4000 --host",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"preview": "astro preview --port 4000",
|
||||
"astro": "astro",
|
||||
"push-s33": "rsync -rv --exclude .hta_config/conf.php dist/ dev2@siliconpin.s33.siliconpin.com:/home/dev2/domains/siliconpin.s33.siliconpin.com/public_html/"
|
||||
"push-prod": "rsync -rv --exclude .hta_config/conf.php dist/ sp@siliconpin.com:~/web/siliconpin.com/public_html/",
|
||||
"proxy": "http://localhost:8080"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/react": "^4.2.1",
|
||||
@@ -16,22 +17,42 @@
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.2.6",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@types/date-fns": "^2.5.3",
|
||||
"@types/react": "^19.0.12",
|
||||
"@uiw/react-markdown-preview": "^5.1.4",
|
||||
"@uiw/react-md-editor": "^3.25.6",
|
||||
"add": "^2.0.6",
|
||||
"astro": "^5.5.2",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-picker-react": "^4.12.3",
|
||||
"framer-motion": "^12.18.1",
|
||||
"image-resize-compress": "^2.1.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.484.0",
|
||||
"marked": "^15.0.8",
|
||||
"menubar": "^9.5.1",
|
||||
"minio": "^8.0.5",
|
||||
"pocketbase": "^0.25.2",
|
||||
"postcss": "^8.5.3",
|
||||
"react-router-dom": "^7.4.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"react-router-dom": "^7.5.1",
|
||||
"react-simplemde-editor": "^5.2.0",
|
||||
"react-to-print": "^3.0.5",
|
||||
"rehype-rewrite": "^4.0.2",
|
||||
"serve": "^14.2.4",
|
||||
"shadcn": "^2.5.0",
|
||||
"simplemde": "^1.11.2",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
3
public/assets/facebook-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
1
public/assets/gitea.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-label="Gitea" role="img" viewBox="0 0 512 512" width="40px" height="40px" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <rect rx="15%" height="512" width="512" fill="#ffffff"></rect> <path d="M419 150c-98 7-186 2-276-1-27 0-63 19-61 67 3 75 71 82 99 83 3 14 35 62 59 65h104c63-5 109-213 75-214zm-311 67c-3-21 7-42 42-42 3 39 10 61 22 96-32-5-59-15-64-54z" fill="#592"></path> <path d="m293 152v70" stroke="#ffffff" stroke-width="9"></path> <g transform="rotate(25.7 496 -423)" stroke-width="7" fill="#592"> <path d="M561 246h97" stroke="#592"></path> <rect x="561" y="246" width="97" height="97" rx="16" fill="#ffffff"></rect> <path d="M592 245v75" stroke="#592"></path> <path d="M592 273c45 0 38-5 38 48" fill="none" stroke="#592"></path> <circle cx="592" cy="320" r="10"></circle> <circle cx="630" cy="320" r="10"></circle> <circle cx="592" cy="273" r="10"></circle> </g> </g></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/assets/images/services/deployapp-thumb.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
public/assets/images/services/kubernetes-edge.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
public/assets/images/services/node-js.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
public/assets/images/services/stt-streaming.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
public/assets/images/services/wiregurd-thumb.jpg
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
public/assets/images/thumb-place.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
12
public/assets/js/index.min.js
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Skipped minification because the original files appears to be already minified.
|
||||
* Original file: /npm/image-resize-compress@2.1.1/dist/index.js
|
||||
*
|
||||
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
|
||||
*/
|
||||
var h=e=>({png:"image/png",webp:"image/webp",bmp:"image/bmp",gif:"image/gif",jpeg:"image/jpeg"})[e]||"image/jpeg",w=(e,t="auto",r="auto")=>{let o=t==="auto"||t===0,m=r==="auto"||r===0;if(!o&&!m)return{width:t,height:r};if(!o){let n=e.naturalWidth/t;return{width:t,height:Math.round((e.naturalHeight/n+Number.EPSILON)*100)/100}}if(!m){let n=e.naturalHeight/r;return{width:Math.round((e.naturalWidth/n+Number.EPSILON)*100)/100,height:r}}return{width:e.naturalWidth,height:e.naturalHeight}},E=async(e,t=100,r="auto",o="auto",m=null,n=null)=>{if(!(e instanceof Blob))throw new TypeError(`Expected a Blob or File, but got ${typeof e}.`);if(e.size===0)throw new Error("Failed to load the image. The file might be corrupt or empty.");if(t<=0)throw new RangeError("Quality must be greater than 0.");if(typeof r=="number"&&r<0||typeof o=="number"&&o<0)throw new RangeError("Invalid width or height value!");let a=m?h(m):e.type,g=t<1?t:t/100;return new Promise((p,l)=>{let u=new FileReader;u.onload=()=>{let s=new Image;s.src=u.result,s.onload=()=>{let i=document.createElement("canvas"),d=w(s,r,o);i.width=d.width,i.height=d.height;let f=i.getContext("2d");if(!f){l(new Error("Failed to get canvas context."));return}n&&a==="image/png"&&(f.fillStyle=n,f.fillRect(0,0,i.width,i.height)),f.drawImage(s,0,0,i.width,i.height),i.toBlob(b=>{if(b===null)return l(new Error("Failed to generate image blob."));p(new Blob([b],{type:a}))},a,g)},s.onerror=()=>{l(new Error("Failed to load the image. The file might be corrupt or empty."))}},u.onerror=()=>l(new Error("Failed to read the blob as a Data URL.")),u.readAsDataURL(e)})},c=E;var F=async(e,t=100,r="auto",o="auto",m,n)=>{try{let a=await fetch(e,n);if(!a.ok)throw new Error(`Failed to fetch image: ${a.statusText}`);let g=await a.blob();return await c(g,t,r,o,m)}catch(a){throw new Error(`Failed to process the image from URL. Check CORS or network issues. Error: ${a}`)}},y=F;var I=e=>new Promise((t,r)=>{if(e.size===0)return r(new Error("Cannot convert empty Blob."));if(e.size>10485760)return r(new Error("File size exceeds the maximum allowed limit."));let o=new FileReader;o.onloadend=()=>{o.result?t(o.result):r(new Error("Failed to convert blob to DataURL."))},o.onerror=()=>r(new Error("Error reading blob.")),o.readAsDataURL(e)}),R=I;var L=async(e,t)=>{try{let r=await fetch(e,t);if(!r.ok)throw new Error(`Failed to fetch image: ${r.statusText}`);return await r.blob()}catch(r){throw new Error(`Failed to fetch image from URL. Error: ${r}`)}},T=L;export{R as blobToURL,c as fromBlob,y as fromURL,T as urlToBlob};
|
||||
/*!
|
||||
* image-resize-compress
|
||||
* Copyright(c) 2024 Álef Duarte
|
||||
* MIT Licensed
|
||||
*/
|
||||
3
public/assets/linkedin-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 437 B |
1381
public/assets/md-editor/mdeditor.css
Normal file
1
public/assets/paypal-logo-white.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="64px" height="64px" viewBox="0 -140 780 780" enable-background="new 0 0 780 500" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="m168.38 169.35c-8.399-5.774-19.359-8.668-32.88-8.668h-52.346c-4.145 0-6.435 2.073-6.87 6.215l-21.264 133.48c-0.221 1.311 0.107 2.51 0.981 3.6 0.869 1.092 1.962 1.635 3.271 1.635h24.864c4.361 0 6.758-2.068 7.198-6.215l5.888-35.986c0.215-1.744 0.982-3.162 2.291-4.254 1.308-1.09 2.944-1.803 4.907-2.129 1.963-0.324 3.814-0.488 5.562-0.488 1.743 0 3.814 0.111 6.217 0.328 2.397 0.217 3.925 0.324 4.58 0.324 18.756 0 33.478-5.285 44.167-15.867 10.684-10.576 16.032-25.242 16.032-44.004 0-12.868-4.203-22.191-12.598-27.974zm-26.989 40.08c-1.094 7.635-3.926 12.649-8.506 15.049-4.581 2.403-11.124 3.599-19.629 3.599l-10.797 0.326 5.563-35.007c0.434-2.397 1.851-3.597 4.252-3.597h6.218c8.72 0 15.049 1.257 18.975 3.761 3.924 2.51 5.233 7.801 3.924 15.869z" fill="#003087"></path><path d="m720.79 160.68h-24.207c-2.406 0-3.822 1.2-4.254 3.601l-21.266 136.1-0.328 0.654c0 1.096 0.436 2.127 1.311 3.109 0.867 0.98 1.963 1.471 3.27 1.471h21.596c4.137 0 6.428-2.068 6.871-6.215l21.264-133.81v-0.325c-1e-3 -3.055-1.423-4.581-4.257-4.581z" fill="#009CDE"></path><path d="m428.31 213.36c0-1.088-0.438-2.126-1.305-3.105-0.875-0.981-1.857-1.475-2.945-1.475h-25.191c-2.404 0-4.367 1.096-5.891 3.271l-34.678 51.039-14.395-49.074c-1.096-3.487-3.492-5.236-7.197-5.236h-24.541c-1.093 0-2.074 0.492-2.941 1.475-0.875 0.979-1.309 2.019-1.309 3.105 0 0.439 2.127 6.871 6.379 19.303 4.252 12.436 8.832 25.85 13.74 40.246 4.908 14.393 7.469 22.031 7.688 22.896-17.886 24.432-26.825 37.518-26.825 39.26 0 2.838 1.415 4.254 4.253 4.254h25.191c2.398 0 4.36-1.088 5.89-3.27l83.427-120.4c0.433-0.432 0.65-1.192 0.65-2.29z" fill="#003087"></path><path d="m662.89 208.78h-24.865c-3.057 0-4.904 3.6-5.559 10.799-5.678-8.722-16.031-13.089-31.084-13.089-15.703 0-29.064 5.89-40.076 17.668-11.016 11.778-16.521 25.632-16.521 41.552 0 12.871 3.762 23.121 11.285 30.752 7.525 7.639 17.611 11.451 30.266 11.451 6.324 0 12.758-1.311 19.301-3.926 6.543-2.617 11.664-6.105 15.379-10.469 0 0.219-0.223 1.197-0.654 2.941-0.441 1.748-0.656 3.061-0.656 3.926 0 3.494 1.414 5.234 4.254 5.234h22.576c4.139 0 6.541-2.068 7.193-6.215l13.416-85.39c0.215-1.31-0.111-2.507-0.982-3.599-0.877-1.088-1.965-1.635-3.273-1.635zm-42.694 64.454c-5.562 5.453-12.27 8.178-20.121 8.178-6.328 0-11.449-1.742-15.377-5.234-3.928-3.482-5.891-8.281-5.891-14.395 0-8.064 2.727-14.886 8.182-20.447 5.445-5.562 12.213-8.342 20.283-8.342 6.102 0 11.174 1.799 15.213 5.396 4.031 3.6 6.055 8.562 6.055 14.889-2e-3 7.851-2.783 14.505-8.344 19.955z" fill="#009CDE"></path><path d="m291.23 208.78h-24.865c-3.058 0-4.908 3.6-5.563 10.799-5.889-8.722-16.25-13.089-31.081-13.089-15.704 0-29.065 5.89-40.078 17.668-11.016 11.778-16.521 25.632-16.521 41.552 0 12.871 3.763 23.121 11.288 30.752 7.525 7.639 17.61 11.451 30.262 11.451 6.104 0 12.433-1.311 18.975-3.926 6.543-2.617 11.778-6.105 15.704-10.469-0.875 2.615-1.309 4.906-1.309 6.867 0 3.494 1.417 5.234 4.253 5.234h22.574c4.141 0 6.543-2.068 7.198-6.215l13.413-85.39c0.215-1.31-0.111-2.507-0.981-3.599-0.873-1.088-1.962-1.635-3.269-1.635zm-42.695 64.616c-5.563 5.35-12.382 8.016-20.447 8.016-6.329 0-11.4-1.742-15.214-5.234-3.819-3.482-5.726-8.281-5.726-14.395 0-8.064 2.725-14.886 8.18-20.447 5.449-5.562 12.211-8.343 20.284-8.343 6.104 0 11.175 1.8 15.214 5.397 4.032 3.6 6.052 8.562 6.052 14.889-1e-3 8.07-2.781 14.779-8.343 20.117z" fill="#003087"></path><path d="m540.04 169.35c-8.398-5.774-19.355-8.668-32.879-8.668h-52.02c-4.363 0-6.764 2.073-7.197 6.215l-21.266 133.48c-0.221 1.311 0.107 2.51 0.982 3.6 0.865 1.092 1.961 1.635 3.27 1.635h26.826c2.617 0 4.361-1.416 5.236-4.252l5.889-37.949c0.217-1.744 0.98-3.162 2.291-4.254 1.309-1.09 2.943-1.803 4.908-2.129 1.961-0.324 3.812-0.488 5.561-0.488 1.744 0 3.814 0.111 6.215 0.328 2.398 0.217 3.93 0.324 4.58 0.324 18.76 0 33.479-5.285 44.168-15.867 10.688-10.576 16.031-25.242 16.031-44.004 1e-3 -12.868-4.2-22.192-12.595-27.974zm-33.533 53.819c-4.799 3.271-11.998 4.906-21.592 4.906l-10.471 0.328 5.562-35.008c0.432-2.396 1.85-3.598 4.252-3.598h5.887c4.799 0 8.615 0.219 11.455 0.654 2.83 0.438 5.561 1.799 8.178 4.088 2.619 2.291 3.926 5.619 3.926 9.979 0 9.164-2.402 15.377-7.197 18.651z" fill="#009CDE"></path></g></svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
22
public/assets/payu-logo.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 514.56 280.44">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #414042;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #00ad7d;
|
||||
}
|
||||
.cls-2:hover {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M0,97.77h25.72v12.86c2.93-4.46,7.37-8.1,13.3-10.92,5.93-2.82,12.3-4.23,19.11-4.23,8.1,0,15.56,2.06,22.37,6.17,6.81,4.11,12.27,9.98,16.38,17.62,4.11,7.64,6.16,16.56,6.16,26.78s-2.06,19.29-6.16,26.86c-4.11,7.58-9.6,13.36-16.47,17.35-6.87,3.99-14.3,5.99-22.28,5.99-6.93,0-13.36-1.38-19.29-4.14-5.93-2.76-10.31-6.37-13.12-10.83v99.18H0V97.77ZM69.67,165.94c4.76-5.17,7.13-11.8,7.13-19.9s-2.38-14.94-7.13-20.17c-4.76-5.22-10.89-7.84-18.41-7.84s-13.68,2.61-18.5,7.84c-4.82,5.23-7.22,11.95-7.22,20.17s2.41,14.74,7.22,19.9c4.81,5.17,10.98,7.75,18.5,7.75s13.65-2.58,18.41-7.75ZM134.67,190.25c-6.87-3.99-12.36-9.78-16.47-17.35-4.11-7.58-6.17-16.53-6.17-26.86s2.05-19.14,6.17-26.78c4.11-7.63,9.57-13.5,16.38-17.62,6.81-4.11,14.27-6.17,22.37-6.17,6.81,0,13.18,1.41,19.11,4.23,5.93,2.82,10.36,6.46,13.3,10.92v-12.33h25.72v95.13h-25.72v-12.15c-2.82,4.46-7.2,8.07-13.12,10.83-5.93,2.76-12.36,4.14-19.29,4.14-7.99,0-15.41-2-22.28-5.99ZM182.41,165.94c4.76-5.17,7.14-11.8,7.14-19.9s-2.38-14.94-7.14-20.17c-4.76-5.22-10.89-7.84-18.41-7.84s-13.68,2.61-18.5,7.84c-4.82,5.23-7.22,11.95-7.22,20.17s2.41,14.74,7.22,19.9c4.81,5.17,10.98,7.75,18.5,7.75s13.65-2.58,18.41-7.75ZM272.69,196.24c-14.56,0-25.31-3.7-32.24-11.1-6.93-7.4-10.39-17.73-10.39-31v-56.37h26.07v53.73c0,7.4,1.94,12.98,5.81,16.73,3.88,3.76,9.22,5.64,16.03,5.64,6.22,0,11.6-2.05,16.12-6.17,4.52-4.11,6.78-9.69,6.78-16.73v-53.2h25.72v182.68h-25.72v-96.71c-6.11,8.34-15.5,12.51-28.19,12.51ZM348.44,252.44l142.86-43.16V75.57l-164.71-41.93v40.87h-25.72V0l213.68,56.19v171.75l-166.12,51.62v-27.13Z"/>
|
||||
<path class="cls-2" d="M362.18,97.77h26.07v53.73c0,15.03,7.1,22.55,21.32,22.55s21.32-7.51,21.32-22.55v-53.73h26.07v56.37c0,13.15-4.05,23.46-12.15,30.91-8.1,7.46-19.85,11.19-35.23,11.19s-27.13-3.76-35.23-11.27c-8.1-7.52-12.16-17.79-12.16-30.83v-56.37Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
3
public/assets/reddit-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.5.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.12-.07 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.963-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
public/assets/twitter-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 533 B |
@@ -1,7 +1,3 @@
|
||||
User-agent: *
|
||||
Disallow: /secret-location/
|
||||
Disallow: /*
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://siliconpin.com/sitemap.xml
|
||||
360
src/components/AddBalance.jsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import QRCode from "react-qr-code";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle} from "./ui/dialog";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card";
|
||||
import { FileX, Copy, CheckCircle2, AlertCircle, X } from "lucide-react";
|
||||
import Loader from "./ui/loader";
|
||||
|
||||
const PUBLIC_USER_API_URL = 'https://siliconpin.com/v1/balance/';
|
||||
// const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
|
||||
export default function AddBalance() {
|
||||
const [orderData, setOrderData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showQRModal, setShowQRModal] = useState(false);
|
||||
const [showHelpMessage, setShowHelpMessage] = useState(false);
|
||||
const [upiPaymentLink, setUpiPaymentLink] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [orderId, setOrderId] = useState('');
|
||||
const [transactionId, setTransactionId] = useState('');
|
||||
const [paymentResponse, setPaymentResponse] = useState({
|
||||
message: '',
|
||||
visible: false,
|
||||
isSuccess: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const orderId = urlParams.get('orderId');
|
||||
|
||||
if (!orderId) {
|
||||
setError('Order ID is missing from URL');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderId(orderId);
|
||||
fetchOrderData(orderId);
|
||||
}, []);
|
||||
|
||||
const fetchOrderData = async (orderId) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('order_id', orderId);
|
||||
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=get-initiated-add-balance`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type' : 'application/json'
|
||||
},
|
||||
body: JSON.stringify({order_id:orderId}),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Failed to fetch payment details');
|
||||
}
|
||||
|
||||
setOrderData(data);
|
||||
setError(null);
|
||||
|
||||
// Generate UPI payment link
|
||||
if (data.payment_data?.amount) {
|
||||
const upiLink = generateUPILink(data.payment_data.amount, data.txn_id);
|
||||
setUpiPaymentLink(upiLink);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error.message || 'Failed to load payment details. Please try again.');
|
||||
console.error('Error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
function generateUPILink(amount, transactionId) {
|
||||
const merchantUPI = "7001601485@okbizaxis";
|
||||
const merchantName = "SiliconPin";
|
||||
const currency = "INR";
|
||||
const transactionNote = encodeURIComponent(`Balance top-up #${transactionId}`);
|
||||
|
||||
return `upi://pay?pa=${merchantUPI}&pn=${encodeURIComponent(merchantName)}&am=${amount}&cu=${currency}&tn=${transactionNote}`;
|
||||
}
|
||||
|
||||
const redirectToPayU = () => {
|
||||
if (!orderData?.payment_data || !orderData.payment_url) {
|
||||
console.error('Payment data not loaded yet');
|
||||
alert('Payment information is not ready. Please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a form dynamically
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = orderData.payment_url;
|
||||
form.style.display = 'none';
|
||||
|
||||
Object.entries(orderData.payment_data).forEach(([key, value]) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = key;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
};
|
||||
|
||||
const handlePayPalPayment = () => {
|
||||
const rawInrAmount = orderData.payment_data?.amount || 0;
|
||||
const exchangeRate = 80;
|
||||
const convertedUSD = rawInrAmount / exchangeRate;
|
||||
const usdAmount = Math.max(convertedUSD, 1).toFixed(2);
|
||||
const paypalUrl = `https://www.paypal.com/paypalme/dwdconsultancy/${usdAmount}`;
|
||||
window.open(paypalUrl, '_blank');
|
||||
};
|
||||
|
||||
const handleQRPaymentClick = () => {
|
||||
if(orderData.currency !== 'INR'){
|
||||
return
|
||||
}
|
||||
if (!upiPaymentLink) {
|
||||
alert('Payment information is not ready. Please wait.');
|
||||
return;
|
||||
}
|
||||
setShowQRModal(true);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(orderId)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy text: ', err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSavePayment = async () => {
|
||||
try {
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=save-balance-payment`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orderId: orderId,
|
||||
transactionId: transactionId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setPaymentResponse({
|
||||
message: `Payment of ₹${orderData?.payment_data?.amount} recorded. Your balance will be updated shortly.`,
|
||||
visible: true,
|
||||
isSuccess: true
|
||||
});
|
||||
} else {
|
||||
throw new Error(data.message || 'Payment verification failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setPaymentResponse({
|
||||
message: 'Failed to verify payment. Please contact support.',
|
||||
visible: true,
|
||||
isSuccess: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (showQRModal) {
|
||||
setShowHelpMessage(false);
|
||||
timer = setTimeout(() => {
|
||||
setShowHelpMessage(true);
|
||||
}, 2000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [showQRModal]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (!orderData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-grow py-10 text-center px-4 text-gray-600 mt-20">
|
||||
<FileX className="mb-4 text-gray-500" size={100} />
|
||||
<p className="text-lg">No payment request found. <a className="text-[#6d9e37] font-medium hover:underline" href="/profile">Click here</a> to go to your profile.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formattedBalance = new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: orderData.currency, // fallback if undefined
|
||||
maximumFractionDigits: 2,
|
||||
}).format(orderData.payment_data?.amount || 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4">
|
||||
<Card className="max-w-md w-full bg-white p-6 rounded-lg shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Add Balance to Your Account</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{orderData && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="font-bold">Order ID:</span>
|
||||
<span>{orderData.txn_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Amount:</span>
|
||||
<span>{formattedBalance}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-medium">Pay Using:</span>
|
||||
<hr className="border-b border-b-[#6d9e37] mb-2" />
|
||||
|
||||
<Button disabled={orderData.currency !== 'INR'} onClick={handleQRPaymentClick}>UPI QR Code</Button>
|
||||
{
|
||||
orderData.currency !== 'INR' && (
|
||||
<p className="text-center text-xs text-gray-500">UPI QR Code Payment support only for INR currency</p>
|
||||
)
|
||||
}
|
||||
<span className="text-center text-gray-400 text-xs">Banking Service by DWD Consultancy Services (0% Processing Fee)</span>
|
||||
|
||||
<div className="flex items-center my-2">
|
||||
<div className="flex-grow border-t border-gray-500"></div>
|
||||
<span className="mx-2 text-gray-500 font-semibold">OR</span>
|
||||
<div className="flex-grow border-t border-gray-500"></div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
disabled={orderData.currency !== 'INR'}
|
||||
className="text-[#414042] font-bold border-[2px] border-[#00ad7d] hover:bg-[#00ad7d]/80"
|
||||
onClick={redirectToPayU}
|
||||
variant="outline"
|
||||
>
|
||||
Pay with
|
||||
<img className="w-[50px]" src="/assets/payu-logo.svg" alt="PayU" />
|
||||
</Button>
|
||||
{
|
||||
orderData.currency !== 'INR' && (
|
||||
<p className="text-center text-xs text-gray-500">Pay with PayU support only for INR currency</p>
|
||||
)
|
||||
}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-2 text-[#414042] font-bold border-[2px] border-b-[#003087] border-r-[#003087] border-t-[#009CDE] border-l-[#009CDE] hover:bg-gradient-to-r hover:from-[#009CDE] hover:to-[#003087]"
|
||||
onClick={handlePayPalPayment}
|
||||
>
|
||||
Pay with
|
||||
<img className="w-[50px]" src="/assets/paypal-logo-white.svg" alt="PayPal" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* QR Code Modal */}
|
||||
<Dialog open={showQRModal} onOpenChange={(open) => {setShowQRModal(open); if (!open) {setShowHelpMessage(false);}}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scan QR Code to Add Balance</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="p-4 bg-white rounded-lg border border-gray-200">
|
||||
<QRCode value={upiPaymentLink} size={256} level="H" />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
Scan this QR code with any UPI app to complete your payment
|
||||
</p>
|
||||
|
||||
<Button variant="outline" onClick={handleCopy}>
|
||||
{copied ? 'Copied!' : orderId}
|
||||
{!copied && <Copy size={15} className="ml-2" />}
|
||||
</Button>
|
||||
|
||||
{showHelpMessage && (
|
||||
<div className="w-full mt-4 p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<p className="text-sm text-yellow-800 mb-3">
|
||||
If you haven't received payment confirmation, please share your transaction ID with us.
|
||||
</p>
|
||||
<Input
|
||||
type="text"
|
||||
value={transactionId}
|
||||
onChange={(e) => setTransactionId(e.target.value)}
|
||||
placeholder="Enter UPI transaction ID"
|
||||
className="w-full mb-2"
|
||||
/>
|
||||
<Button onClick={handleSavePayment} className="w-full">
|
||||
Verify Payment
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Payment Response Toast */}
|
||||
{paymentResponse.visible && (
|
||||
<div className="fixed bottom-4 left-0 right-0 flex justify-center">
|
||||
<div className={`p-4 rounded-lg shadow-lg max-w-md w-full mx-4 ${
|
||||
paymentResponse.isSuccess
|
||||
? 'bg-green-50 border border-green-200 text-green-800'
|
||||
: 'bg-red-50 border border-red-200 text-red-800'
|
||||
}`}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
{paymentResponse.isSuccess ? (
|
||||
<CheckCircle2 className="w-5 h-5 mr-2 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 mr-2 text-red-500" />
|
||||
)}
|
||||
<span>{paymentResponse.message}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPaymentResponse(prev => ({...prev, visible: false}))}
|
||||
className="p-1 rounded-full hover:bg-opacity-20 hover:bg-current"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/components/AvatarUpload.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { X } from "lucide-react";
|
||||
import PocketBase from 'pocketbase';
|
||||
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
const PUBLIC_POCKETBASE_URL = import.meta.env.PUBLIC_POCKETBASE_URL;
|
||||
const pb = new PocketBase(PUBLIC_POCKETBASE_URL);
|
||||
|
||||
export function AvatarUpload({ userId }: { userId: string }) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setSelectedFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !userId) return;
|
||||
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
// 1. Upload to PocketBase
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', selectedFile);
|
||||
|
||||
// Update PocketBase user record
|
||||
const pbRecord = await pb.collection('users').update(userId, formData);
|
||||
|
||||
// Get the avatar URL from PocketBase
|
||||
const avatarUrl = pb.getFileUrl(pbRecord, pbRecord.avatar, {
|
||||
thumb: '100x100'
|
||||
});
|
||||
|
||||
// 2. Update PHP backend session
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Important for sessions to work
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
avatar: avatarUrl,
|
||||
query: 'avatar_update'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update session');
|
||||
}
|
||||
|
||||
// Success - you might want to refresh the user data or show a success message
|
||||
window.location.reload();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading avatar:', error);
|
||||
alert('Failed to update avatar');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!selectedFile ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<Label
|
||||
htmlFor="avatar"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground py-2 px-4 text-sm rounded-md cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
Change Avatar
|
||||
</Label>
|
||||
<Input
|
||||
type="file"
|
||||
id="avatar"
|
||||
ref={fileInputRef}
|
||||
accept="image/jpeg,image/png,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
Browse
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JPG, GIF or PNG. 1MB max.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-3 space-x-2">
|
||||
<div className="truncate max-w-[200px]">
|
||||
<p className="text-sm font-medium truncate">{selectedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{(selectedFile.size / 1024).toFixed(2)} KB</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="text-xs p-1 h-fit"
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading...' : 'Update'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRemoveFile}
|
||||
className="bg-red-500 hover:bg-red-600 text-xs p-1 h-fit"
|
||||
disabled={isUploading}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
607
src/components/BillingInfo.jsx
Normal file
@@ -0,0 +1,607 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import Loader from "./ui/loader";
|
||||
import { PDFDownloadLink } from '@react-pdf/renderer';
|
||||
import InvoicePDF from "../lib/InvoicePDF";
|
||||
import { Eye, Download, ChevronUp, ChevronDown, Search, FileText } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
import * as XLSX from 'xlsx';
|
||||
export default function UserBillingList() {
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
const [billingData, setBillingData] = useState([]);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// Sorting state
|
||||
const [sortConfig, setSortConfig] = useState({ key: 'created_at', direction: 'desc' });
|
||||
|
||||
// Filtering state
|
||||
const [filters, setFilters] = useState({ status: '', cycle: '', service: '' });
|
||||
|
||||
// Search state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage] = useState(20);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
fetchBillingData();
|
||||
}
|
||||
|
||||
}, [isLoggedIn]);
|
||||
|
||||
const fetchBillingData = async () => {
|
||||
try {
|
||||
const res = await fetch(`${PUBLIC_USER_API_URL}?query=invoice-info`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
const data = await res.json();
|
||||
// console.log('Resoponse Data', data)
|
||||
// For regular users, filter to only show their own billing data
|
||||
const filteredData = sessionData?.user_type === 'admin' || 'user' ? data.data || [] : (data.data || []).filter(item => item.user === sessionData?.email);
|
||||
|
||||
// console.log('Session Data', sessionData);
|
||||
setBillingData(filteredData);
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
// console.log('billing data', billingData)
|
||||
const handleViewItem = (item) => {
|
||||
setSelectedItem(item);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setDialogOpen(false);
|
||||
// setSelectedItem(null);
|
||||
};
|
||||
|
||||
// Sorting functionality
|
||||
const requestSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Filter and sort data
|
||||
const filteredAndSortedData = useMemo(() => {
|
||||
let filteredData = [...billingData];
|
||||
|
||||
// Apply search
|
||||
if (searchTerm) {
|
||||
filteredData = filteredData.filter(item =>
|
||||
Object.values(item).some(
|
||||
val => val && val.toString().toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (filters.status) {
|
||||
filteredData = filteredData.filter(item => item.status === filters.status);
|
||||
}
|
||||
if (filters.cycle) {
|
||||
filteredData = filteredData.filter(item => item.cycle === filters.cycle);
|
||||
}
|
||||
if (filters.service) {
|
||||
filteredData = filteredData.filter(item => item.service === filters.service);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sortConfig.key) {
|
||||
filteredData.sort((a, b) => {
|
||||
if (a[sortConfig.key] < b[sortConfig.key]) {
|
||||
return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
}
|
||||
if (a[sortConfig.key] > b[sortConfig.key]) {
|
||||
return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filteredData;
|
||||
}, [billingData, searchTerm, filters, sortConfig]);
|
||||
|
||||
const currentItems = useMemo(() => {
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
return filteredAndSortedData.slice(indexOfFirstItem, indexOfLastItem);
|
||||
}, [currentPage, itemsPerPage, filteredAndSortedData]);
|
||||
|
||||
const totalPages = Math.ceil(filteredAndSortedData.length / itemsPerPage);
|
||||
|
||||
const paginate = (pageNumber) => {
|
||||
if (pageNumber > 0 && pageNumber <= totalPages) {
|
||||
setCurrentPage(pageNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const getPaginationRange = () => {
|
||||
const range = [];
|
||||
const maxVisiblePages = 5;
|
||||
range.push(1);
|
||||
if (currentPage > 3) {
|
||||
range.push('...');
|
||||
}
|
||||
let start = Math.max(2, currentPage - 1);
|
||||
let end = Math.min(totalPages - 1, currentPage + 1);
|
||||
if (currentPage <= 3) {
|
||||
end = Math.min(4, totalPages - 1);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
start = Math.max(totalPages - 3, 2);
|
||||
}
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (i > 1 && i < totalPages) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
if (currentPage < totalPages - 2) {
|
||||
range.push('...');
|
||||
}
|
||||
if (totalPages > 1) {
|
||||
range.push(totalPages);
|
||||
}
|
||||
return range;
|
||||
};
|
||||
|
||||
// Get unique values for filter dropdowns
|
||||
const uniqueServices = [...new Set(billingData.map(item => item.service))];
|
||||
const uniqueStatuses = ['completed', 'pending', 'failed'];
|
||||
const uniquecycles = ['monthly', 'yearly', 'one-time'];
|
||||
|
||||
// Reset filters
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
status: '',
|
||||
cycle: '',
|
||||
service: ''
|
||||
});
|
||||
setSearchTerm('');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Handle filter change
|
||||
const handleFilterChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
const exportToExcel = () => {
|
||||
const worksheet = XLSX.utils.json_to_sheet(filteredAndSortedData.map(item => ({
|
||||
'Billing ID': item.billing_id,
|
||||
'Service': item.service,
|
||||
'Customer Name': item.name,
|
||||
'Customer Email': item.user,
|
||||
'Silicon ID': item.siliconId,
|
||||
'Amount': item.amount,
|
||||
'cycle': item.cycle,
|
||||
'Status': item.status,
|
||||
'Created At': formatDate(item.created_at),
|
||||
'Remarks': item.remarks
|
||||
})));
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Billing Data");
|
||||
XLSX.writeFile(workbook, `billing_data_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
|
||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount) return '$0.00';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(parseFloat(amount));
|
||||
};
|
||||
|
||||
if (loading || dataLoading) return <Loader />;
|
||||
if (error || apiError) return <p>Error: {error?.message || apiError}</p>;
|
||||
if (!isLoggedIn) {
|
||||
return <p className="text-center mt-8">You need to be logged in to view this page. <a href="/" className="text-[#6d9e37]">Click Here</a> to go to the homepage.</p>;
|
||||
}
|
||||
// console.log('currentItems', currentItems)
|
||||
return (
|
||||
<section className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">My Billing History</h1>
|
||||
<Button onClick={exportToExcel} variant="outline" className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" /> Export to Excel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Results Count */}
|
||||
<div className="mb-2 text-sm text-gray-600">
|
||||
Showing {currentItems.length} of {filteredAndSortedData.length} results
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-t-lg shadow">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
className="bg-[#262626] pl-10 w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
name="status"
|
||||
value={filters.status}
|
||||
onChange={handleFilterChange}
|
||||
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{uniqueStatuses.map(status => (
|
||||
<option key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* cycle Filter */}
|
||||
<select
|
||||
name="cycle"
|
||||
value={filters.cycle}
|
||||
onChange={handleFilterChange}
|
||||
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
|
||||
>
|
||||
<option value="">All cycles</option>
|
||||
{uniquecycles.map(cycle => (
|
||||
<option key={cycle} value={cycle}>
|
||||
{cycle.charAt(0).toUpperCase() + cycle.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Service Filter */}
|
||||
<select
|
||||
name="service"
|
||||
value={filters.service}
|
||||
onChange={handleFilterChange}
|
||||
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
|
||||
>
|
||||
<option value="">All Services</option>
|
||||
{uniqueServices.map(service => (
|
||||
<option key={service} value={service}>
|
||||
{service}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reset Filters Button */}
|
||||
{(filters.status || filters.cycle || filters.service || searchTerm) && (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
onClick={resetFilters}
|
||||
size="sm"
|
||||
>
|
||||
Reset Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white rounded-b-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('billing_id')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Billing ID
|
||||
{sortConfig.key === 'billing_id' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('service')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Service
|
||||
{sortConfig.key === 'service' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('name')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
{sortConfig.key === 'name' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('amount')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Amount
|
||||
{sortConfig.key === 'amount' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('status')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
{sortConfig.key === 'status' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('created_at')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Created At
|
||||
{sortConfig.key === 'created_at' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentItems.length > 0 ? (
|
||||
currentItems.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{item.billing_id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.service}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{item.name??item.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">
|
||||
{formatCurrency(item.amount)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(item.status)}`}>
|
||||
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(item.created_at)}
|
||||
</td>
|
||||
<td className="inline-flex px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
title="View Details"
|
||||
onClick={() => handleViewItem(item)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-2"
|
||||
>
|
||||
<Eye className="w-5 h-5" />
|
||||
</button>
|
||||
<PDFDownloadLink
|
||||
title="Download PDF"
|
||||
document={<InvoicePDF data={item} />}
|
||||
fileName={`invoice_${item.billing_id}.pdf`}
|
||||
className="text-[#6d9e37] hover:text-green-600"
|
||||
>
|
||||
{({ loading }) => (loading ? '...' : <Download className="w-5 h-5" />)}
|
||||
</PDFDownloadLink>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="7" className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
No records found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredAndSortedData.length > itemsPerPage && (
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => paginate(1)}
|
||||
disabled={currentPage === 1}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
First
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => paginate(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{getPaginationRange().map((item, index) => {
|
||||
if (item === '...') {
|
||||
return (
|
||||
<span key={`ellipsis-${index}`} className="px-2 py-1">
|
||||
...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={`page-${item}`}
|
||||
onClick={() => paginate(item)}
|
||||
variant={currentPage === item ? "default" : "outline"}
|
||||
size="sm"
|
||||
>
|
||||
{item}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
onClick={() => paginate(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => paginate(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Last
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog for View */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="">
|
||||
Billing Details
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Detailed information about your billing record
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-950">Billing ID</h3>
|
||||
<p className="text-neutral-400 font-medium">{selectedItem?.billing_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-950">Service</h3>
|
||||
<p className="text-neutral-400 font-medium">{selectedItem?.service}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-950">User</h3>
|
||||
<p className="text-neutral-400 font-medium">{selectedItem?.user}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-950">cycle</h3>
|
||||
<p className="text-neutral-400 font-medium">{selectedItem?.cycle}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-950">Amount</h3>
|
||||
<p className="text-neutral-400 font-medium">{formatCurrency(selectedItem?.amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-950">Status</h3>
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(selectedItem?.status)}`}>
|
||||
{selectedItem?.status?.charAt(0).toUpperCase() + selectedItem?.status?.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-950">Created At</h3>
|
||||
<p className="text-neutral-400 font-medium">{formatDate(selectedItem?.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-950">Updated At</h3>
|
||||
<p className="text-neutral-400 font-medium">{formatDate(selectedItem?.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<PDFDownloadLink
|
||||
document={<InvoicePDF data={selectedItem} />}
|
||||
fileName={`invoice_${selectedItem?.billing_id}.pdf`}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
{({ loading }) => (loading ? 'Preparing PDF...' : 'Download PDF')}
|
||||
</PDFDownloadLink>
|
||||
<Button
|
||||
onClick={closeModal}
|
||||
variant="outline"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
183
src/components/BuyServices/BuyVPN.jsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
|
||||
export default function BuyVPN() {
|
||||
// State for VPN configuration
|
||||
const [vpnContinent, setVpnContinent] = useState("");
|
||||
const [selectedCycle, setSelectedCycle] = useState("");
|
||||
const [selectedPrice, setSelectedPrice] = useState(0);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Price configuration
|
||||
const PRICE_CONFIG = {
|
||||
monthly: 100,
|
||||
yearly: 1000
|
||||
};
|
||||
|
||||
// Handle billing cycle selection
|
||||
const handleBillingCycleChange = (cycle) => {
|
||||
setSelectedCycle(cycle);
|
||||
setSelectedPrice(cycle === 'monthly' ? PRICE_CONFIG.monthly : PRICE_CONFIG.yearly);
|
||||
setError(null); // Clear any previous errors when user makes a selection
|
||||
};
|
||||
|
||||
// Handle VPN purchase
|
||||
const handlePurchase = async () => {
|
||||
// Validate inputs
|
||||
if (!vpnContinent) {
|
||||
setError('Please select a continent');
|
||||
return;
|
||||
}
|
||||
if (!selectedCycle) {
|
||||
setError('Please select a billing cycle');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('service', 'VPN WireGuard');
|
||||
formData.append('serviceId', 'vpnservices');
|
||||
formData.append('cycle', selectedCycle);
|
||||
formData.append('amount', selectedPrice.toString());
|
||||
formData.append('vpn_continent', vpnContinent);
|
||||
formData.append('service_type', 'vpn');
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=initiate_payment`, {
|
||||
method: 'POST',
|
||||
body: formData, // Using FormData instead of JSON
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Redirect to payment success page with order ID
|
||||
window.location.href = `/success?service=vpn&continent=${vpnContinent}&orderId=${data.order_id}`;
|
||||
} else {
|
||||
throw new Error(data.message || 'Payment initialization failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Purchase error:', error);
|
||||
setError(error.message || 'An error occurred during purchase. Please try again.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-2xl mx-auto mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle>VPN WireGuard</CardTitle>
|
||||
<CardDescription>
|
||||
Purchase your secure VPN subscription
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Continent Selection */}
|
||||
<div>
|
||||
<label htmlFor="vpn-continent" className="block text-sm font-medium mb-1">
|
||||
Select Continent
|
||||
</label>
|
||||
<Select name="vpn-continent" onValueChange={setVpnContinent} value={vpnContinent} disabled={isProcessing}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="-Select-" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="india">India</SelectItem>
|
||||
<SelectItem value="america">America</SelectItem>
|
||||
<SelectItem value="europe">Europe</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* VPN Features */}
|
||||
<ul className="flex flex-wrap justify-between gap-2 text-xs">
|
||||
{["600 GB bandwidth", "WireGuard® protocol", "Global server locations", "No activity logs"].map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[#6d9e37] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className='text-zinc-400'>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Billing Cycle Selection */}
|
||||
{
|
||||
vpnContinent && (
|
||||
<div className='flex flex-row justify-between items-center gap-x-6'>
|
||||
<label className={`border ${selectedCycle === 'monthly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer ${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="vpn-cycle"
|
||||
checked={selectedCycle === 'monthly'}
|
||||
onChange={() => handleBillingCycleChange('monthly')}
|
||||
className="hidden"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<p className='text-3xl font-bold text-center'>₹{PRICE_CONFIG.monthly}</p>
|
||||
<span>Monthly</span>
|
||||
</label>
|
||||
|
||||
<label className={`border ${selectedCycle === 'yearly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer ${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="vpn-cycle"
|
||||
checked={selectedCycle === 'yearly'}
|
||||
onChange={() => handleBillingCycleChange('yearly')}
|
||||
className="hidden"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<p className='text-3xl font-bold text-center'>₹{PRICE_CONFIG.yearly}</p>
|
||||
<span>Yearly</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Selection Summary */}
|
||||
{selectedCycle && (
|
||||
<p className={`text-white ${selectedCycle === 'monthly' ? 'text-left' : 'text-end'}`}>
|
||||
You selected <strong>{selectedCycle}</strong> plan at ₹{selectedPrice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-900/20 border border-red-700/50 rounded-md">
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Purchase Button */}
|
||||
<Button
|
||||
onClick={handlePurchase}
|
||||
className={`w-full`}
|
||||
disabled={!vpnContinent || !selectedCycle || isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
'Proceed'
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
// Cash Expense:- 950
|
||||
// Card Expense:- 580
|
||||
22
src/components/BuyServices/KebernetisPayload.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
let endpoint = 'https://api.utho.com/v2/kubernetes/deploy';
|
||||
let kuberPayload = {
|
||||
"dcslug": "inmumbaizone2",
|
||||
"cluster_label": "MyK8S-flZuUYtk-nvn9h",
|
||||
"cluster_version": "1.30.0-utho",
|
||||
"nodepools": [
|
||||
{
|
||||
"label": "pool-GPlb5CRP",
|
||||
"size": "10215",
|
||||
"count": "2",
|
||||
"ebs": [
|
||||
{
|
||||
"disk": "30",
|
||||
"type": "nvme"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"vpc": "81b2bd94-61dc-424b-a1ca-ca4c810ed4c4",
|
||||
"network_type": "publicprivate",
|
||||
"cpumodel": "amd"
|
||||
}
|
||||
470
src/components/BuyServices/Kubernetis.jsx
Normal file
@@ -0,0 +1,470 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import Loader from "../ui/loader";
|
||||
import { useToast } from "../ui/toast";
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
|
||||
export default function Kubernetes() {
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
const { showToast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetchingData, setIsFetchingData] = useState(true);
|
||||
const [deployError, setDeployError] = useState(null);
|
||||
const [deployStatus, setDeployStatus] = useState({});
|
||||
const [vpcs, setVpcs] = useState([]);
|
||||
const [nodePools, setNodePools] = useState([{ label: '', size: '', count: 1, ebs: [{ disk: '', type: 'nvme' }] }]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
dcslug: 'inmumbaizone2',
|
||||
cluster_label: '',
|
||||
cluster_version: '1.30.0-utho',
|
||||
vpc: '',
|
||||
network_type: 'publicprivate',
|
||||
cpumodel: 'amd'
|
||||
});
|
||||
|
||||
const availableSizes = [
|
||||
{ id: '10215', name: '2 vCPU, 4GB RAM' },
|
||||
{ id: '10216', name: '4 vCPU, 8GB RAM' },
|
||||
{ id: '10217', name: '8 vCPU, 16GB RAM' },
|
||||
{ id: '10218', name: '16 vCPU, 32GB RAM' }
|
||||
];
|
||||
|
||||
const availableVersions = [
|
||||
'1.30.0-utho',
|
||||
'1.29.0-utho',
|
||||
'1.28.0-utho'
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Simulate fetching VPCs - replace with actual API call
|
||||
const mockVpcs = [
|
||||
{ id: '81b2bd94-61dc-424b-a1ca-ca4c810ed4c4', name: 'Default VPC (Mumbai)' },
|
||||
{ id: 'c17032c9-3cfd-4028-8f2a-f3f5aa8c2976', name: 'Default VPC (Bangalore)' }
|
||||
];
|
||||
setVpcs(mockVpcs);
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to load configuration data",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
nodepools: nodePools.map(pool => ({
|
||||
...pool,
|
||||
count: String(pool.count), // Ensure count is string as per API
|
||||
ebs: pool.ebs.map(disk => ({
|
||||
...disk,
|
||||
disk: String(disk.disk) // Ensure disk size is string
|
||||
}))
|
||||
}))
|
||||
};
|
||||
|
||||
const response = await fetch("https://host-api.cs1.hz.siliconpin.com/v1/kubernetis/?query=deploy&source=chanel_1", {
|
||||
credentials: "include",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
setDeployStatus({
|
||||
status: data.status,
|
||||
clusterId: data.clusterId,
|
||||
endpoint: data.endpoint
|
||||
});
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to deploy Kubernetes cluster");
|
||||
}
|
||||
} catch (error) {
|
||||
setDeployError(error.message);
|
||||
showToast({
|
||||
title: "Deployment Error",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleNodePoolChange = (index, field, value) => {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools[index][field] = value;
|
||||
setNodePools(updatedPools);
|
||||
};
|
||||
|
||||
const handleDiskChange = (poolIndex, diskIndex, field, value) => {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools[poolIndex].ebs[diskIndex][field] = value;
|
||||
setNodePools(updatedPools);
|
||||
};
|
||||
|
||||
const addNodePool = () => {
|
||||
setNodePools([...nodePools, { label: '', size: '', count: 1, ebs: [{ disk: '', type: 'nvme' }] }]);
|
||||
};
|
||||
|
||||
const removeNodePool = (index) => {
|
||||
if (nodePools.length > 1) {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools.splice(index, 1);
|
||||
setNodePools(updatedPools);
|
||||
}
|
||||
};
|
||||
|
||||
const addDisk = (poolIndex) => {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools[poolIndex].ebs.push({ disk: '', type: 'nvme' });
|
||||
setNodePools(updatedPools);
|
||||
};
|
||||
|
||||
const removeDisk = (poolIndex, diskIndex) => {
|
||||
const updatedPools = [...nodePools];
|
||||
if (updatedPools[poolIndex].ebs.length > 1) {
|
||||
updatedPools[poolIndex].ebs.splice(diskIndex, 1);
|
||||
setNodePools(updatedPools);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || (isLoggedIn && isFetchingData)) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (deployError) return <p>Error: {deployError}</p>;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <p className="text-center mt-8">
|
||||
You are not Logged in. <a href="/login" className="text-[#6d9e37]">Click Here</a> to login first then you can add this service.
|
||||
</p>;
|
||||
}
|
||||
|
||||
if (deployStatus.status === 'success') {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Kubernetes Cluster Deployed Successfully</CardTitle>
|
||||
<CardDescription>Your Kubernetes cluster is now being provisioned.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Cluster ID:</Label>
|
||||
<p className="font-mono p-2 bg-gray-100 rounded">{deployStatus.clusterId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>API Endpoint:</Label>
|
||||
<p className="font-mono p-2 bg-gray-100 rounded">{deployStatus.endpoint}</p>
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<Button onClick={() => window.location.href = '/services/kubernetes'}>
|
||||
Back to Kubernetes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Kubernetes Cluster</CardTitle>
|
||||
<CardDescription>Configure your Kubernetes cluster with the desired node pools and settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cluster_label">Cluster Name *</Label>
|
||||
<Input
|
||||
id="cluster_label"
|
||||
name="cluster_label"
|
||||
value={formData.cluster_label}
|
||||
onChange={handleChange}
|
||||
placeholder="my-cluster"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dcslug">Data Center *</Label>
|
||||
<Select
|
||||
name="dcslug"
|
||||
value={formData.dcslug}
|
||||
onValueChange={(value) => setFormData({...formData, dcslug: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inmumbaizone2">Mumbai</SelectItem>
|
||||
<SelectItem value="inbangalore">Bangalore</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cluster_version">Kubernetes Version *</Label>
|
||||
<Select
|
||||
name="cluster_version"
|
||||
value={formData.cluster_version}
|
||||
onValueChange={(value) => setFormData({...formData, cluster_version: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVersions.map(version => (
|
||||
<SelectItem key={version} value={version}>
|
||||
{version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpc">VPC *</Label>
|
||||
<Select
|
||||
name="vpc"
|
||||
value={formData.vpc}
|
||||
onValueChange={(value) => setFormData({...formData, vpc: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select VPC" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vpcs.map(vpc => (
|
||||
<SelectItem key={vpc.id} value={vpc.id}>
|
||||
{vpc.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Network Type *</Label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="network_type"
|
||||
value="publicprivate"
|
||||
checked={formData.network_type === 'publicprivate'}
|
||||
onChange={handleChange}
|
||||
className="text-[#6d9e37]"
|
||||
/>
|
||||
<span>Public + Private</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="network_type"
|
||||
value="private"
|
||||
checked={formData.network_type === 'private'}
|
||||
onChange={handleChange}
|
||||
className="text-[#6d9e37]"
|
||||
/>
|
||||
<span>Private Only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>CPU Model *</Label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="cpumodel"
|
||||
value="amd"
|
||||
checked={formData.cpumodel === 'amd'}
|
||||
onChange={handleChange}
|
||||
className="text-[#6d9e37]"
|
||||
/>
|
||||
<span>AMD</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="cpumodel"
|
||||
value="intel"
|
||||
checked={formData.cpumodel === 'intel'}
|
||||
onChange={handleChange}
|
||||
className="text-[#6d9e37]"
|
||||
/>
|
||||
<span>Intel</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-lg">Node Pools</Label>
|
||||
<Button type="button" onClick={addNodePool} variant="outline">
|
||||
Add Node Pool
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{nodePools.map((pool, poolIndex) => (
|
||||
<div key={poolIndex} className="p-4 border rounded-lg space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-medium">Node Pool #{poolIndex + 1}</h4>
|
||||
{nodePools.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeNodePool(poolIndex)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Pool Label</Label>
|
||||
<Input
|
||||
value={pool.label}
|
||||
onChange={(e) => handleNodePoolChange(poolIndex, 'label', e.target.value)}
|
||||
placeholder="worker-pool"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Node Size *</Label>
|
||||
<Select
|
||||
value={pool.size}
|
||||
onValueChange={(value) => handleNodePoolChange(poolIndex, 'size', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSizes.map(size => (
|
||||
<SelectItem key={size.id} value={size.id}>
|
||||
{size.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Node Count *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={pool.count}
|
||||
onChange={(e) => handleNodePoolChange(poolIndex, 'count', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-md">EBS Volumes</Label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => addDisk(poolIndex)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Add Disk
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pool.ebs.map((disk, diskIndex) => (
|
||||
<div key={diskIndex} className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label>Disk Size (GB) *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={disk.disk}
|
||||
onChange={(e) => handleDiskChange(poolIndex, diskIndex, 'disk', e.target.value)}
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Disk Type *</Label>
|
||||
<Select
|
||||
value={disk.type}
|
||||
onValueChange={(value) => handleDiskChange(poolIndex, diskIndex, 'type', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nvme">NVMe SSD</SelectItem>
|
||||
<SelectItem value="ssd">Standard SSD</SelectItem>
|
||||
<SelectItem value="hdd">HDD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{pool.ebs.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeDisk(poolIndex, diskIndex)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={isLoading} className="w-full md:w-auto">
|
||||
{isLoading ? "Deploying Cluster..." : "Deploy Kubernetes Cluster"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
215
src/components/BuyServices/NewDroplet.jsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useToast } from "../ui/toast";
|
||||
|
||||
export default function NewDroplet() {
|
||||
const PUBLIC_DIGITALOCEAN_API_KEY = import.meta.env.PUBLIC_DIGITALOCEAN_API_KEY;
|
||||
const { showToast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
region: "",
|
||||
size: "",
|
||||
image: "",
|
||||
backups: false,
|
||||
ipv6: true,
|
||||
monitoring: false
|
||||
});
|
||||
|
||||
const regions = [
|
||||
{ value: "nyc1", label: "New York 1" },
|
||||
{ value: "nyc3", label: "New York 3" },
|
||||
{ value: "sfo3", label: "San Francisco 3" },
|
||||
{ value: "ams3", label: "Amsterdam 3" },
|
||||
];
|
||||
|
||||
const sizes = [
|
||||
{ value: "s-1vcpu-1gb", label: "1 vCPU, 1GB RAM" },
|
||||
{ value: "s-1vcpu-2gb", label: "1 vCPU, 2GB RAM" },
|
||||
{ value: "s-2vcpu-2gb", label: "2 vCPU, 2GB RAM" },
|
||||
];
|
||||
|
||||
const images = [
|
||||
{ value: "ubuntu-22-04-x64", label: "Ubuntu 22.04" },
|
||||
{ value: "ubuntu-20-04-x64", label: "Ubuntu 20.04" },
|
||||
{ value: "debian-11-x64", label: "Debian 11" },
|
||||
];
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectChange = (name, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("https://api.digitalocean.com/v2/droplets", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${PUBLIC_DIGITALOCEAN_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
ssh_keys: [47441478]
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
showToast(`Droplet ${formData.name} created successfully!`, { type: 'success' });
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: "",
|
||||
region: "nyc3",
|
||||
size: "s-1vcpu-1gb",
|
||||
image: "ubuntu-22-04-x64",
|
||||
backups: false,
|
||||
ipv6: true,
|
||||
monitoring: false
|
||||
});
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to create droplet");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.message, { type: 'error' });
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-xl mx-auto px-4 my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Droplet</CardTitle>
|
||||
<CardDescription>Configure your new cloud server</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Droplet Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="my-droplet"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Region</Label>
|
||||
<Select
|
||||
value={formData.region}
|
||||
onValueChange={(value) => handleSelectChange("region", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{regions.map(region => (
|
||||
<SelectItem key={region.value} value={region.value}>
|
||||
{region.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Size</Label>
|
||||
<Select
|
||||
value={formData.size}
|
||||
onValueChange={(value) => handleSelectChange("size", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sizes.map(size => (
|
||||
<SelectItem key={size.value} value={size.value}>
|
||||
{size.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Image</Label>
|
||||
<Select
|
||||
value={formData.image}
|
||||
onValueChange={(value) => handleSelectChange("image", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select OS image" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{images.map(image => (
|
||||
<SelectItem key={image.value} value={image.value}>
|
||||
{image.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="backups"
|
||||
name="backups"
|
||||
checked={formData.backups}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="backups">Enable Backups</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="ipv6"
|
||||
name="ipv6"
|
||||
checked={formData.ipv6}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Label htmlFor="ipv6">Enable IPv6</Label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
"Create Droplet"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
374
src/components/BuyServices/NewHetznerCloudInstance.jsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import Loader from "../ui/loader";
|
||||
import { useToast } from "../ui/toast";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export default function NewHetznerInstance() {
|
||||
const { showToast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetchingData, setIsFetchingData] = useState(true);
|
||||
const [serverTypes, setServerTypes] = useState([]);
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [images, setImages] = useState([]);
|
||||
const [sshKeys, setSshKeys] = useState([]);
|
||||
const [showAddSshKey, setShowAddSshKey] = useState(false);
|
||||
const PUBLIC_HETZNER_API_KEY = import.meta.env.PUBLIC_HETZNER_API_KEY;
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
server_type: "",
|
||||
image: "",
|
||||
location: "nbg1", // Default to Nuremberg
|
||||
ssh_keys: [],
|
||||
user_data: "",
|
||||
backups: false,
|
||||
start_after_create: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch all required data in parallel
|
||||
const [serverTypesResponse, locationsResponse, imagesResponse, sshKeysResponse] = await Promise.all([
|
||||
fetch("https://api.hetzner.cloud/v1/server_types", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||
}),
|
||||
fetch("https://api.hetzner.cloud/v1/locations", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||
}),
|
||||
fetch("https://api.hetzner.cloud/v1/images", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||
}),
|
||||
fetch("https://api.hetzner.cloud/v1/ssh_keys", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||
})
|
||||
]);
|
||||
|
||||
const serverTypesData = await serverTypesResponse.json();
|
||||
const locationsData = await locationsResponse.json();
|
||||
const imagesData = await imagesResponse.json();
|
||||
const sshKeysData = await sshKeysResponse.json();
|
||||
|
||||
setServerTypes(serverTypesData.server_types || []);
|
||||
setLocations(locationsData.locations || []);
|
||||
setImages(imagesData.images.filter(img => img.type === "system") || []);
|
||||
setSshKeys(sshKeysData.ssh_keys || []);
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to load configuration data",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
server_type: formData.server_type,
|
||||
image: formData.image,
|
||||
location: formData.location,
|
||||
backups: formData.backups,
|
||||
ssh_keys: formData.ssh_keys,
|
||||
start_after_create: formData.start_after_create
|
||||
};
|
||||
|
||||
// Add user_data if provided
|
||||
if (formData.user_data) {
|
||||
payload.user_data = formData.user_data;
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.hetzner.cloud/v1/servers", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.server) {
|
||||
showToast({
|
||||
title: "Deployment Started",
|
||||
description: `Server ${data.server.name} is being deployed`,
|
||||
variant: "success"
|
||||
});
|
||||
// Reset form after successful deployment
|
||||
setFormData({
|
||||
name: "",
|
||||
server_type: "",
|
||||
image: "",
|
||||
location: "nbg1",
|
||||
ssh_keys: [],
|
||||
user_data: "",
|
||||
backups: false,
|
||||
start_after_create: true
|
||||
});
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to deploy instance");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Deployment Failed",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddSshKey = async () => {
|
||||
try {
|
||||
const response = await fetch('https://api.hetzner.cloud/v1/ssh_keys', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: formData.newSshKeyName,
|
||||
public_key: formData.newSshKeyValue
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ssh_key) {
|
||||
// Refresh SSH keys list
|
||||
const sshResponse = await fetch("https://api.hetzner.cloud/v1/ssh_keys", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||
});
|
||||
const sshData = await sshResponse.json();
|
||||
setSshKeys(sshData.ssh_keys || []);
|
||||
|
||||
showToast({
|
||||
title: "SSH Key Added",
|
||||
description: "Your SSH key has been successfully added",
|
||||
variant: "success"
|
||||
});
|
||||
|
||||
setShowAddSshKey(false);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to add SSH key",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetchingData) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Deploy New Hetzner Cloud Server</CardTitle>
|
||||
<CardDescription>Configure your cloud server with essential parameters</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Server Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="my-hetzner-server"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="location">Location *</Label>
|
||||
<Select
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onValueChange={(value) => setFormData({...formData, location: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{locations.map(location => (
|
||||
<SelectItem key={location.name} value={location.name}>
|
||||
{`${location.city} (${location.name.toUpperCase()})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server_type">Server Type *</Label>
|
||||
<Select
|
||||
name="server_type"
|
||||
value={formData.server_type}
|
||||
onValueChange={(value) => setFormData({...formData, server_type: value})}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{serverTypes.map(type => (
|
||||
<SelectItem key={type.name} value={type.name}>
|
||||
{`${type.name} - ${type.cores} vCPU, ${type.memory}GB RAM`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image">Operating System *</Label>
|
||||
<Select
|
||||
name="image"
|
||||
value={formData.image}
|
||||
onValueChange={(value) => setFormData({...formData, image: value})}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select OS" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{images.map(image => (
|
||||
<SelectItem key={image.id} value={image.id.toString()}>
|
||||
{image.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">SSH Keys</h3>
|
||||
<div className="space-y-2">
|
||||
<Label>Select SSH Keys</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formData.ssh_keys[0] || ""}
|
||||
onValueChange={(value) => setFormData({...formData, ssh_keys: [value]})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select SSH key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sshKeys.map(key => (
|
||||
<SelectItem key={key.id} value={key.id.toString()}>{key.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setShowAddSshKey(!showAddSshKey)}
|
||||
>
|
||||
{showAddSshKey ? "Cancel" : "Add New"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddSshKey && (
|
||||
<div className="space-y-4 p-4 border rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newSshKeyName">Key Name</Label>
|
||||
<Input
|
||||
id="newSshKeyName"
|
||||
name="newSshKeyName"
|
||||
value={formData.newSshKeyName}
|
||||
onChange={handleChange}
|
||||
placeholder="My Laptop Key"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newSshKeyValue">Public Key</Label>
|
||||
<Textarea
|
||||
id="newSshKeyValue"
|
||||
name="newSshKeyValue"
|
||||
value={formData.newSshKeyValue}
|
||||
onChange={handleChange}
|
||||
placeholder="ssh-rsa AAAAB3NzaC1yc2E..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddSshKey}
|
||||
disabled={!formData.newSshKeyName || !formData.newSshKeyValue}
|
||||
>
|
||||
Add SSH Key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user_data">Cloud-Init User Data (Optional)</Label>
|
||||
<Textarea
|
||||
id="user_data"
|
||||
name="user_data"
|
||||
value={formData.user_data}
|
||||
onChange={handleChange}
|
||||
placeholder="#cloud-config\nwrite_files:\n - content: |\n Hello World\n path: /tmp/hello.txt"
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Options</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="backups"
|
||||
checked={formData.backups}
|
||||
onCheckedChange={(checked) => setFormData({...formData, backups: checked})}
|
||||
/>
|
||||
<Label htmlFor="backups">Enable Backups (+20% cost)</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={isLoading} className="w-full md:w-auto">
|
||||
{isLoading ? "Deploying..." : "Deploy Server"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
487
src/components/BuyServices/NewKubernetisUtho copy.jsx
Normal file
@@ -0,0 +1,487 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import Loader from "../ui/loader";
|
||||
import { useToast } from "../ui/toast";
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
|
||||
export default function Kubernetes() {
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
const { showToast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetchingData, setIsFetchingData] = useState(true);
|
||||
const [deployError, setDeployError] = useState(null);
|
||||
const [deployStatus, setDeployStatus] = useState({});
|
||||
const [vpcs, setVpcs] = useState([]);
|
||||
const [nodePools, setNodePools] = useState([{ label: `${getRandomString()}`, size: '10215', count: 1, ebs: [{ disk: 5, type: 'nvme' }] }]);
|
||||
|
||||
const [formData, setFormData] = useState({ dcslug: 'inmumbaizone2', cluster_label: `${getRandomString()}`, cluster_version: '1.30.0-utho', network_type: 'publicprivate', });
|
||||
function getRandomString( length = 10, { includeNumbers = true, includeLowercase = true, includeUppercase = true, } = {} ) {
|
||||
let chars = '';
|
||||
const numbers = '0123456789';
|
||||
const lowercase = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const uppercase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
|
||||
if (includeNumbers) chars += numbers;
|
||||
if (includeLowercase) chars += lowercase;
|
||||
if (includeUppercase) chars += uppercase;
|
||||
|
||||
if (!chars) {
|
||||
throw new Error('At least one character type must be included');
|
||||
}
|
||||
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
const availableSizes = [
|
||||
{ id: '10215', name: '2 vCPU, 4GB RAM' },
|
||||
{ id: '10216', name: '4 vCPU, 8GB RAM' },
|
||||
{ id: '10217', name: '8 vCPU, 16GB RAM' },
|
||||
{ id: '10218', name: '16 vCPU, 32GB RAM' }
|
||||
];
|
||||
|
||||
const availableVersions = [
|
||||
'1.30.0-utho',
|
||||
'1.29.0-utho',
|
||||
'1.28.0-utho'
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Simulate fetching VPCs - replace with actual API call
|
||||
const mockVpcs = [
|
||||
{ id: '81b2bd94-61dc-424b-a1ca-ca4c810ed4c4', name: 'Default VPC (Mumbai)' },
|
||||
{ id: 'c17032c9-3cfd-4028-8f2a-f3f5aa8c2976', name: 'Default VPC (Bangalore)' }
|
||||
];
|
||||
setVpcs(mockVpcs);
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to load configuration data",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
nodepools: nodePools.map(pool => ({
|
||||
...pool,
|
||||
count: String(pool.count), // Ensure count is string as per API
|
||||
ebs: pool.ebs.map(disk => ({
|
||||
...disk,
|
||||
disk: String(disk.disk) // Ensure disk size is string
|
||||
}))
|
||||
}))
|
||||
};
|
||||
|
||||
const response = await fetch("https://host-api.cs1.hz.siliconpin.com/v1/kubernetis/?query=deploy&source=chanel_1", {
|
||||
credentials: "include",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
setDeployStatus({
|
||||
status: data.status,
|
||||
clusterId: data.clusterId,
|
||||
endpoint: data.endpoint
|
||||
});
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to deploy Kubernetes cluster");
|
||||
}
|
||||
} catch (error) {
|
||||
setDeployError(error.message);
|
||||
showToast({
|
||||
title: "Deployment Error",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleNodePoolChange = (index, field, value) => {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools[index][field] = value;
|
||||
setNodePools(updatedPools);
|
||||
};
|
||||
|
||||
const handleDiskChange = (poolIndex, diskIndex, field, value) => {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools[poolIndex].ebs[diskIndex][field] = value;
|
||||
setNodePools(updatedPools);
|
||||
};
|
||||
|
||||
const addNodePool = () => {
|
||||
setNodePools([...nodePools, { label: '', size: '', count: 1, ebs: [{ disk: '', type: 'nvme' }] }]);
|
||||
};
|
||||
|
||||
const removeNodePool = (index) => {
|
||||
if (nodePools.length > 1) {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools.splice(index, 1);
|
||||
setNodePools(updatedPools);
|
||||
}
|
||||
};
|
||||
|
||||
const addDisk = (poolIndex) => {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools[poolIndex].ebs.push({ disk: '', type: 'nvme' });
|
||||
setNodePools(updatedPools);
|
||||
};
|
||||
|
||||
const removeDisk = (poolIndex, diskIndex) => {
|
||||
const updatedPools = [...nodePools];
|
||||
if (updatedPools[poolIndex].ebs.length > 1) {
|
||||
updatedPools[poolIndex].ebs.splice(diskIndex, 1);
|
||||
setNodePools(updatedPools);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || (isLoggedIn && isFetchingData)) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (deployError) return <p>Error: {deployError}</p>;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <p className="text-center mt-8">
|
||||
You are not Logged in. <a href="/login" className="text-[#6d9e37]">Click Here</a> to login first then you can add this service.
|
||||
</p>;
|
||||
}
|
||||
|
||||
if (deployStatus.status === 'success') {
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Kubernetes Cluster Deployed Successfully</CardTitle>
|
||||
<CardDescription>Your Kubernetes cluster is now being provisioned.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Cluster ID:</Label>
|
||||
<p className="font-mono p-2 bg-gray-100 rounded">{deployStatus.id}</p>
|
||||
</div>
|
||||
{
|
||||
|
||||
}
|
||||
{/* <div>
|
||||
<Label>API Endpoint:</Label>
|
||||
<p className="font-mono p-2 bg-gray-100 rounded">{deployStatus.endpoint}</p>
|
||||
</div> */}
|
||||
<div className="pt-4">
|
||||
<Button onClick={() => window.location.href = '/services/kubernetis'}>
|
||||
Back to Kubernetes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Kubernetes Cluster</CardTitle>
|
||||
<CardDescription>Configure your Kubernetes cluster with the desired node pools and settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cluster_label">Cluster Name *</Label>
|
||||
<Input
|
||||
id="cluster_label"
|
||||
name="cluster_label"
|
||||
value={formData.cluster_label}
|
||||
onChange={handleChange}
|
||||
placeholder="my-cluster"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* <div className="space-y-2">
|
||||
<Label htmlFor="dcslug">Data Center *</Label>
|
||||
<Select
|
||||
name="dcslug"
|
||||
value={formData.dcslug}
|
||||
onValueChange={(value) => setFormData({...formData, dcslug: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem selected value="inmumbaizone2">Mumbai</SelectItem>
|
||||
<SelectItem value="inbangalore">Bangalore</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cluster_version">Kubernetes Version *</Label>
|
||||
<Select
|
||||
name="cluster_version"
|
||||
value={formData.cluster_version}
|
||||
onValueChange={(value) => setFormData({...formData, cluster_version: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableVersions.map(version => (
|
||||
<SelectItem key={version} value={version}>
|
||||
{version}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* <div className="space-y-2">
|
||||
<Label htmlFor="vpc">VPC *</Label>
|
||||
<Select
|
||||
name="vpc"
|
||||
value={formData.vpc}
|
||||
onValueChange={(value) => setFormData({...formData, vpc: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select VPC" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vpcs.map(vpc => (
|
||||
<SelectItem key={vpc.id} value={vpc.id}>
|
||||
{vpc.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div> */}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Network Type *</Label>
|
||||
<div className="flex gap-4 border border-neutral-600 rounded-md p-2">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="network_type"
|
||||
value="publicprivate"
|
||||
checked={formData.network_type === 'publicprivate'}
|
||||
onChange={handleChange}
|
||||
className="text-[#6d9e37] accent-[#6d9e37]"
|
||||
/>
|
||||
<span>Public + Private</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="network_type"
|
||||
value="private"
|
||||
checked={formData.network_type === 'private'}
|
||||
onChange={handleChange}
|
||||
className="text-[#6d9e37] accent-[#6d9e37]"
|
||||
/>
|
||||
<span>Private Only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* <div className="space-y-2">
|
||||
<Label>CPU Model *</Label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="cpumodel"
|
||||
value="amd"
|
||||
checked={formData.cpumodel === 'amd'}
|
||||
onChange={handleChange}
|
||||
className="text-[#6d9e37]"
|
||||
/>
|
||||
<span>AMD</span>
|
||||
</label>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="cpumodel"
|
||||
value="intel"
|
||||
checked={formData.cpumodel === 'intel'}
|
||||
onChange={handleChange}
|
||||
className="text-[#6d9e37]"
|
||||
/>
|
||||
<span>Intel</span>
|
||||
</label>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-lg">Node Pools</Label>
|
||||
<Button type="button" onClick={addNodePool} variant="outline">
|
||||
Add Node Pool
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{nodePools.map((pool, poolIndex) => (
|
||||
<div key={poolIndex} className="p-4 border rounded-lg space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-medium">Node Pool #{poolIndex + 1}</h4>
|
||||
{nodePools.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeNodePool(poolIndex)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Pool Label</Label>
|
||||
<Input
|
||||
value={pool.label}
|
||||
onChange={(e) => handleNodePoolChange(poolIndex, 'label', e.target.value)}
|
||||
placeholder="worker-pool"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Node Size *</Label>
|
||||
<Select
|
||||
value={pool.size}
|
||||
onValueChange={(value) => handleNodePoolChange(poolIndex, 'size', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSizes.map((size, index) => (
|
||||
<SelectItem selected={index === 0} key={size.id} value={size.id}>
|
||||
{size.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Node Count *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={pool.count}
|
||||
onChange={(e) => handleNodePoolChange(poolIndex, 'count', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-md">EBS Volumes</Label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => addDisk(poolIndex)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Add Disk
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pool.ebs.map((disk, diskIndex) => (
|
||||
<div key={diskIndex} className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label>Disk Size (GB) *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={disk.disk}
|
||||
onChange={(e) => handleDiskChange(poolIndex, diskIndex, 'disk', e.target.value)}
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Disk Type *</Label>
|
||||
<Select
|
||||
value={disk.type}
|
||||
onValueChange={(value) => handleDiskChange(poolIndex, diskIndex, 'type', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nvme">NVMe SSD</SelectItem>
|
||||
<SelectItem value="ssd">Standard SSD</SelectItem>
|
||||
<SelectItem value="hdd">HDD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{pool.ebs.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeDisk(poolIndex, diskIndex)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={isLoading} className="w-full md:w-auto">
|
||||
{isLoading ? "Deploying Cluster..." : "Deploy Kubernetes Cluster"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
421
src/components/BuyServices/NewKubernetisUtho.jsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { useToast } from "../ui/toast";
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
import Loader from "../ui/loader";
|
||||
export default function Kubernetes() {
|
||||
// Environment variables and hooks
|
||||
const PUBLIC_DEPLOYMENT_CHANEL_1_URL = import.meta.env.PUBLIC_DEPLOYMENT_CHANEL_1_URL;
|
||||
const { loading, isLoggedIn, balance } = useIsLoggedIn();
|
||||
const { showToast } = useToast();
|
||||
|
||||
// State management
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deployError, setDeployError] = useState(null);
|
||||
const [deployStatus, setDeployStatus] = useState({});
|
||||
const [productAmount] = useState(100);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [downloadClusterId, setDownloadClusterId] = useState('');
|
||||
|
||||
// Form states
|
||||
const [nodePools, setNodePools] = useState([{
|
||||
label: `${getRandomString()}`,
|
||||
size: '10215',
|
||||
count: 1,
|
||||
ebs: [{ disk: 30, type: 'nvme' }]
|
||||
}]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
dcslug: 'inmumbaizone2',
|
||||
cluster_label: `${getRandomString()}`,
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function getRandomString(length = 8) {
|
||||
return Math.random().toString(36).substring(2, length+2);
|
||||
}
|
||||
|
||||
const availableSizes = [
|
||||
{ id: '10215', name: '2 vCPU, 4GB RAM' },
|
||||
{ id: '10216', name: '4 vCPU, 8GB RAM' },
|
||||
{ id: '10217', name: '8 vCPU, 16GB RAM' },
|
||||
{ id: '10218', name: '16 vCPU, 32GB RAM' }
|
||||
];
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
if (balance < productAmount) return;
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setDeployError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
cluster_version: '1.30.0-utho',
|
||||
nodepools: nodePools.map(pool => ({
|
||||
...pool,
|
||||
count: String(pool.count),
|
||||
ebs: pool.ebs.map(disk => ({
|
||||
...disk,
|
||||
disk: String(disk.disk)
|
||||
}))
|
||||
})),
|
||||
vpc: '81b2bd94-61dc-424b-a1ca-ca4c810ed4c4',
|
||||
network_type: 'publicprivate',
|
||||
cpumodel: 'amd'
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${PUBLIC_DEPLOYMENT_CHANEL_1_URL}kubernetis/?query=deploy&source=chanel_1`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error("Deployment request failed");
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
setDeployStatus({
|
||||
status: "success",
|
||||
clusterId: data.id
|
||||
});
|
||||
showToast({
|
||||
title: "Deployment Successful",
|
||||
description: "Your cluster is being provisioned.",
|
||||
variant: "default"
|
||||
});
|
||||
} else {
|
||||
throw new Error(data.message || "Cluster deployment failed");
|
||||
}
|
||||
} catch (error) {
|
||||
setDeployError(error.message);
|
||||
showToast({
|
||||
title: "Deployment Failed",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadKubeConfig = (clusterId) => {
|
||||
if (!clusterId) {
|
||||
showToast({
|
||||
title: "Cluster ID Required",
|
||||
description: "Please enter a valid Cluster ID",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(
|
||||
`${PUBLIC_DEPLOYMENT_CHANEL_1_URL}kubernetis/?query=download&clusterId=${clusterId}&source=chanel_1`,
|
||||
'_blank'
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopy = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
};
|
||||
|
||||
// Form field handlers
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleNodePoolChange = (index, field, value) => {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools[index][field] = value;
|
||||
setNodePools(updatedPools);
|
||||
};
|
||||
|
||||
const handleDiskChange = (poolIndex, diskIndex, field, value) => {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools[poolIndex].ebs[diskIndex][field] = value;
|
||||
setNodePools(updatedPools);
|
||||
};
|
||||
|
||||
const addNodePool = () => {
|
||||
setNodePools([...nodePools, {
|
||||
label: `${getRandomString()}`,
|
||||
size: '10215',
|
||||
count: 1,
|
||||
ebs: [{ disk: 30, type: 'nvme' }]
|
||||
}]);
|
||||
};
|
||||
|
||||
const removeNodePool = (index) => {
|
||||
if (nodePools.length > 1) {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools.splice(index, 1);
|
||||
setNodePools(updatedPools);
|
||||
}
|
||||
};
|
||||
|
||||
const addDisk = (poolIndex) => {
|
||||
const updatedPools = [...nodePools];
|
||||
updatedPools[poolIndex].ebs.push({ disk: 30, type: 'nvme' });
|
||||
setNodePools(updatedPools);
|
||||
};
|
||||
|
||||
const removeDisk = (poolIndex, diskIndex) => {
|
||||
const updatedPools = [...nodePools];
|
||||
if (updatedPools[poolIndex].ebs.length > 1) {
|
||||
updatedPools[poolIndex].ebs.splice(diskIndex, 1);
|
||||
setNodePools(updatedPools);
|
||||
}
|
||||
};
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<p className="text-center mt-8">
|
||||
You must be logged in to deploy clusters. <a href="/login" className="text-[#6d9e37]">Login here</a>.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (balance < productAmount) {
|
||||
return (
|
||||
<p className="text-center mt-8">
|
||||
You have insufficient balance to deploy this service. Please <a href="/profile" className="text-[#6d9e37]">click here</a> to add balance.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl mx-auto mt-8">
|
||||
{/* Deployment Card */}
|
||||
{deployStatus.status === 'success' ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Kubernetes Cluster Deployment</CardTitle>
|
||||
<CardDescription>Save your Cluster ID in a safe place to download configuration file</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex">
|
||||
<Input
|
||||
className="rounded-l-md rounded-r-none text-xl font-bold border-[2px] border-[#6d9e37]"
|
||||
type="text"
|
||||
readOnly
|
||||
value={deployStatus.clusterId}
|
||||
/>
|
||||
<Button
|
||||
className="rounded-l-none rounded-r-md"
|
||||
onClick={() => handleCopy(deployStatus.clusterId)}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Create Kubernetes Cluster</CardTitle>
|
||||
<CardDescription>
|
||||
Configure your Kubernetes cluster with the desired specifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cluster_label">Cluster Name *</Label>
|
||||
<Input
|
||||
id="cluster_label"
|
||||
name="cluster_label"
|
||||
value={formData.cluster_label}
|
||||
onChange={handleChange}
|
||||
placeholder="my-cluster"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-lg">Node Pools</Label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addNodePool}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Add Node Pool
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{nodePools.map((pool, poolIndex) => (
|
||||
<div key={poolIndex} className="p-4 border rounded-lg space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="font-medium">Node Pool #{poolIndex + 1}</h4>
|
||||
{nodePools.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeNodePool(poolIndex)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Pool Label</Label>
|
||||
<Input
|
||||
value={pool.label}
|
||||
onChange={(e) => handleNodePoolChange(poolIndex, 'label', e.target.value)}
|
||||
placeholder="worker-pool"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Node Size *</Label>
|
||||
<Select
|
||||
value={pool.size}
|
||||
onValueChange={(value) => handleNodePoolChange(poolIndex, 'size', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select size" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSizes.map(size => (
|
||||
<SelectItem key={size.id} value={size.id}>
|
||||
{size.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Node Count *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={pool.count}
|
||||
onChange={(e) => handleNodePoolChange(poolIndex, 'count', parseInt(e.target.value) || 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-md">Storage Volumes</Label>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => addDisk(poolIndex)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Add Volume
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pool.ebs.map((disk, diskIndex) => (
|
||||
<div key={diskIndex} className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
|
||||
<div className="space-y-2">
|
||||
<Label>Size (GB) *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={disk.disk}
|
||||
onChange={(e) => handleDiskChange(poolIndex, diskIndex, 'disk', e.target.value)}
|
||||
placeholder="30"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Type *</Label>
|
||||
<Select
|
||||
value={disk.type}
|
||||
onValueChange={(value) => handleDiskChange(poolIndex, diskIndex, 'type', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem selected value="nvme">NVMe SSD</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
{pool.ebs.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeDisk(poolIndex, diskIndex)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
{isLoading ? "Deploying..." : "Deploy Cluster"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Download Config Card - Always visible */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Download Configuration</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Cluster ID to download the kubeconfig file <br /> <span className="text-xs">(Try to download config file after 5 minutes from Deployment!)</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="downloadClusterId">Cluster ID</Label>
|
||||
<Input
|
||||
id="downloadClusterId"
|
||||
value={deployStatus.clusterId || downloadClusterId}
|
||||
onChange={(e) => setDownloadClusterId(e.target.value)}
|
||||
placeholder="Enter your Cluster ID"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => downloadKubeConfig(deployStatus.clusterId || downloadClusterId)}
|
||||
>
|
||||
Download Config
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
709
src/components/BuyServices/NewUthoCloudInstance.jsx
Normal file
@@ -0,0 +1,709 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import Loader from "../ui/loader";
|
||||
import { useToast } from "../ui/toast";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
|
||||
export default function NewCloudInstance() {
|
||||
const PUBLIC_UTHO_API_KEY = import.meta.env.PUBLIC_UTHO_API_KEY;
|
||||
const { isLoggedIn, loading, error, sessionData, balance } = useIsLoggedIn();
|
||||
const { showToast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetchingData, setIsFetchingData] = useState(true);
|
||||
const [deployError, setDeployError] = useState()
|
||||
const [deployStatus, setDeployStatus] = useState({});
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [productAmount, setProductAmount] = useState(100);
|
||||
const [plans, setPlans] = useState([
|
||||
{
|
||||
"id": "10027",
|
||||
"type": "cloud",
|
||||
"slug": "dedicated-cpu",
|
||||
"disk": "80",
|
||||
"ram": "4096",
|
||||
"cpu": "2",
|
||||
"bandwidth": "1000",
|
||||
"dedicated_vcore": "1",
|
||||
"is_available": "YES",
|
||||
"price": 26.83,
|
||||
"price_currency": 26.83,
|
||||
"price_cur": "$26.8"
|
||||
},
|
||||
{
|
||||
"id": "10028",
|
||||
"type": "cloud",
|
||||
"slug": "dedicated-cpu",
|
||||
"disk": "160",
|
||||
"ram": "8192",
|
||||
"cpu": "4",
|
||||
"bandwidth": "2000",
|
||||
"dedicated_vcore": "1",
|
||||
"is_available": "YES",
|
||||
"price": 53.72,
|
||||
"price_currency": 53.72,
|
||||
"price_cur": "$53.7"
|
||||
},
|
||||
{
|
||||
"id": "10029",
|
||||
"type": "cloud",
|
||||
"slug": "dedicated-cpu",
|
||||
"disk": "480",
|
||||
"ram": "32768",
|
||||
"cpu": "8",
|
||||
"bandwidth": "3000",
|
||||
"dedicated_vcore": "1",
|
||||
"is_available": "YES",
|
||||
"price": 151.91,
|
||||
"price_currency": 151.91,
|
||||
"price_cur": "$151.9"
|
||||
},
|
||||
{
|
||||
"id": "10030",
|
||||
"type": "cloud",
|
||||
"slug": "dedicated-cpu",
|
||||
"disk": "960",
|
||||
"ram": "65536",
|
||||
"cpu": "16",
|
||||
"bandwidth": "4000",
|
||||
"dedicated_vcore": "1",
|
||||
"is_available": "YES",
|
||||
"price": 292.19,
|
||||
"price_currency": 292.19,
|
||||
"price_cur": "$292.2"
|
||||
},
|
||||
]);
|
||||
// const [sshKeys, setSshKeys] = useState([]);
|
||||
const [showAddSshKey, setShowAddSshKey] = useState(false);
|
||||
const [vpcs, setVpcs] = useState();
|
||||
const [images, setImages] = useState([
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Alma Linux",
|
||||
"version": "9.2 x86_64",
|
||||
"image": "almalinux-9.2-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Alma Linux",
|
||||
"version": "8.4 x86_64",
|
||||
"image": "AlmaLinux_8.4_x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Centos",
|
||||
"version": "7.3 x86_64",
|
||||
"image": "centos-7.3-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Centos",
|
||||
"version": "8 x86_64",
|
||||
"image": "centos-8-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Centos",
|
||||
"version": "7.9 x86_64",
|
||||
"image": "centos-7.9-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Centos",
|
||||
"version": "Ameyo x86_64",
|
||||
"image": "AmeyoCentos7",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "9.4 x86_64",
|
||||
"image": "debian-9.4-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "9.13 x86_64",
|
||||
"image": "debian-9.13-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "10 x86_64",
|
||||
"image": "debian-10-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "11 x86_64",
|
||||
"image": "debian-11-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "12 x86_64",
|
||||
"image": "debian-12-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Fedora",
|
||||
"version": "34 x86_64",
|
||||
"image": "fedora-34-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Fedora",
|
||||
"version": "32 x86_64",
|
||||
"image": "fedora-32-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Fedora",
|
||||
"version": "33 x86_64",
|
||||
"image": "fedora-33-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "FortiOS",
|
||||
"version": "7.6.2 ",
|
||||
"image": "fortios-762",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Rocky Linux",
|
||||
"version": "8.7 x86_64",
|
||||
"image": "rocky-8.7-86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Rocky Linux",
|
||||
"version": "8.8 x86_64",
|
||||
"image": "rocky-8.8-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Rocky Linux",
|
||||
"version": "9.1 x86_64",
|
||||
"image": "rocky-9.1-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Rocky Linux",
|
||||
"version": "9.2 x86_64",
|
||||
"image": "rocky-9.2-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Ubuntu",
|
||||
"version": "20.04 x86_64",
|
||||
"image": "ubuntu-20.04-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Ubuntu",
|
||||
"version": "18.04 x86_64",
|
||||
"image": "ubuntu-18.10-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Ubuntu",
|
||||
"version": "22.04 x86_64",
|
||||
"image": "ubuntu-22.04-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Windows",
|
||||
"version": "Server 2012R2 x86_64",
|
||||
"image": "windows-server2012r2-x86_64",
|
||||
"cost": 1200
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Windows",
|
||||
"version": "Server 2016 x86_64",
|
||||
"image": "windows-2016",
|
||||
"cost": 1200
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Windows",
|
||||
"version": "Server 2019 x86_64",
|
||||
"image": "windows2019-10nov2020",
|
||||
"cost": 1200
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Windows",
|
||||
"version": "Server 2022 x86_64",
|
||||
"image": "windows-2022",
|
||||
"cost": 1200
|
||||
}
|
||||
])
|
||||
const [formData, setFormData] = useState({
|
||||
dcslug: '',
|
||||
planid: '',
|
||||
billingcycle: "hourly",
|
||||
auth: "option2",
|
||||
enable_publicip: '',
|
||||
subnetRequired: '',
|
||||
firewall: "23434645",
|
||||
cpumodel: "intel",
|
||||
enablebackup: '',
|
||||
root_password: '',
|
||||
support: "unmanaged",
|
||||
vpc: '',
|
||||
cloud: [
|
||||
{
|
||||
hostname: ''
|
||||
}
|
||||
],
|
||||
image: '',
|
||||
sshkeys: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const vpcResponse = await fetch('https://api.utho.com/v2/vpc', {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||
});
|
||||
|
||||
const vpcsData = await vpcResponse.json();
|
||||
|
||||
setVpcs(vpcsData.vpc || []);
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to load configuration data",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
if(balance < productAmount){
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
dcslug: formData.dcslug,
|
||||
planid: formData.planid,
|
||||
billingcycle: "hourly",
|
||||
auth: "option2",
|
||||
enable_publicip: formData.enable_publicip ? true : false,
|
||||
subnetRequired: false,
|
||||
firewall: "23434645",
|
||||
cpumodel: "intel",
|
||||
enablebackup: formData.enablebackup ? true : false,
|
||||
root_password: formData.rootPassword,
|
||||
support: "unmanaged",
|
||||
vpc: formData.dcslug === 'inmumbaizone2' ? '68c36d65-bda8-43cc-94b5-ae28bb2c3306' : formData.dcslug === 'inbangalore' ? 'c17032c9-3cfd-4028-8f2a-f3f5aa8c2976' : '',
|
||||
cloud: [
|
||||
{
|
||||
hostname: formData.hostname
|
||||
}
|
||||
],
|
||||
image: formData.image,
|
||||
sshkeys: formData.sshkeys
|
||||
};
|
||||
// inbangalore = c17032c9-3cfd-4028-8f2a-f3f5aa8c2976
|
||||
// inmumbai = 68c36d65-bda8-43cc-94b5-ae28bb2c3306
|
||||
// Add authentication based on selected method
|
||||
if (formData.auth === "ssh_key") {
|
||||
payload.sshkeys = formData.sshkeys;
|
||||
} else {
|
||||
payload.password = formData.password;
|
||||
}
|
||||
console.log('payload', payload)
|
||||
const response = await fetch("https://host-api.cs1.hz.siliconpin.com/v1/vps/?query=deploy&source=chanel_1", {
|
||||
credentials: "include",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
setDeployStatus({status : data.status, ip : data.ipv4})
|
||||
// showToast({
|
||||
// title: "Deployment Started",
|
||||
// description: `Server ${data.cloudid} is being deployed with IP ${data.ipv4}`,
|
||||
// variant: "success"
|
||||
// });
|
||||
// Reset form after successful deployment
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
hostname: "",
|
||||
planid: ""
|
||||
}));
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to deploy instance");
|
||||
}
|
||||
} catch (error) {
|
||||
setDeployError(error.message)
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddSshKey = async () => {
|
||||
try {
|
||||
const response = await fetch('https://api.utho.com/v2/key/import', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: formData.newSshKeyName,
|
||||
sshkey: formData.newSshKeyValue
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Refresh SSH keys list
|
||||
const sshResponse = await fetch("https://api.utho.com/v2/key", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||
});
|
||||
const sshData = await sshResponse.json();
|
||||
setSshKeys(sshData.key || []);
|
||||
|
||||
showToast({
|
||||
title: "SSH Key Added",
|
||||
description: "Your SSH key has been successfully added",
|
||||
variant: "success"
|
||||
});
|
||||
|
||||
setShowAddSshKey(false);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to add SSH key",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (text) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000); // revert after 1 second
|
||||
};
|
||||
|
||||
|
||||
if (loading || (isLoggedIn && isFetchingData)) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (balance < productAmount) {
|
||||
return (
|
||||
<p>
|
||||
You have insufficient balance to deploy this service. Please <a href="/profile" className="text-[#6d9e37]"> click here </a> to go to your profile and add balance, then try again.</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (deployError) return <p>Error: {deployError?.message}</p>;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <p className="text-center mt-8">
|
||||
You are not Logged in. <a href="/login" className="text-[#6d9e37]">Click Here</a> to login first then you can add this service.
|
||||
</p>;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if(deployStatus.status === 'success'){
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Vertual Private Server (VPS)</CardTitle>
|
||||
<CardDescription>Provide the root password then you can access vps using the password. <br /> later on you can setup ssh key</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>Your New VPS</p>
|
||||
<div className="flex flex-col my-2">
|
||||
<Label>IP Address:</Label>
|
||||
<div className="flex">
|
||||
<Input className="rounded-l-md rounded-r-none text-xl font-bold border-[2px] border-[#6d9e37]" type="text" readOnly value={deployStatus.ip} />
|
||||
<Button className="rounded-l-none rounded-r-md" onClick={() => handleCopy(deployStatus.ip)}>{copied ? 'Copied!' : 'Copy' }</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center place-items-center">
|
||||
<Button className="" onClick={() => window.location.href = '/services/cloud-instance'}>Back</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Create New Vertual Private Server (VPS)</CardTitle>
|
||||
<CardDescription>Provide the root password then you can access vps using the password. <br /> later on you can setup ssh key</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hostname">Hostname *</Label>
|
||||
<Input
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
value={formData.hostname}
|
||||
onChange={handleChange}
|
||||
placeholder="server1.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dcslug">Data Center *</Label>
|
||||
<Select
|
||||
name="dcslug"
|
||||
value={formData.dcslug}
|
||||
onValueChange={(value) => setFormData({...formData, dcslug: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inmumbaizone2">Mumbai</SelectItem>
|
||||
<SelectItem value="inbangalore">Bangalore</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="planid">Plan *</Label>
|
||||
<Select
|
||||
name="planid"
|
||||
value={formData.planid}
|
||||
onValueChange={(value) => setFormData({...formData, planid: value})}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map(plan => (
|
||||
<SelectItem key={plan.id} value={plan.id}>
|
||||
{`${plan.cpu} vCPU, ${plan.ram}MB RAM`} {/* {`${plan.cpu} vCPU, ${plan.ram}MB RAM - ${plan.price_cur}/mo`} */}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* <SelectItem key={plans[0].id} value={plans[0].id}>
|
||||
{`${plans[0].cpu} vCPU, ${plans[0].ram}MB RAM - ${plans[0].price_cur}/mo`}
|
||||
</SelectItem> */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image">Operating System *</Label>
|
||||
<Select
|
||||
name="image"
|
||||
value={formData.image}
|
||||
onValueChange={(value) => setFormData({...formData, image: value})}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select OS" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{
|
||||
images.map((image, i) => (
|
||||
<SelectItem key={i} value={image.image}>{image.image.split('-x86_64')[0]}</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Root Password *</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter root password"
|
||||
required={formData.auth === "password"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
<Tabs
|
||||
value={formData.auth}
|
||||
onValueChange={(value) => setFormData({...formData, auth: value})}
|
||||
className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 gap-4">
|
||||
<TabsTrigger className={`p-2 rounded-md ${formData.auth === 'ssh_key' ? 'bg-[#6d9e37] text-[#FFF]' : 'border border-[#6d9e37]'}`} value="ssh_key">SSH Key</TabsTrigger>
|
||||
<TabsTrigger className={`p-2 rounded-md ${formData.auth === 'password' ? 'bg-[#6d9e37] text-[#FFF]' : 'border border-[#6d9e37]'}`} value="password">Password</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ssh_key" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select SSH Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formData.sshkeys}
|
||||
onValueChange={(value) => setFormData({...formData, sshkeys: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select SSH key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sshKeys.map(key => (
|
||||
<SelectItem key={key.id} value={key.id}>{key.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setShowAddSshKey(!showAddSshKey)}
|
||||
>
|
||||
{showAddSshKey ? "Cancel" : "Add New"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddSshKey && (
|
||||
<div className="space-y-4 p-4 border rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newSshKeyName">Key Name</Label>
|
||||
<Input
|
||||
id="newSshKeyName"
|
||||
name="newSshKeyName"
|
||||
value={formData.newSshKeyName}
|
||||
onChange={handleChange}
|
||||
placeholder="My Laptop Key"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newSshKeyValue">Public Key</Label>
|
||||
<Textarea
|
||||
id="newSshKeyValue"
|
||||
name="newSshKeyValue"
|
||||
value={formData.newSshKeyValue}
|
||||
onChange={handleChange}
|
||||
placeholder="ssh-rsa AAAAB3NzaC1yc2E..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddSshKey}
|
||||
disabled={!formData.newSshKeyName || !formData.newSshKeyValue}
|
||||
>
|
||||
Add SSH Key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="password" className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Root Password *</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter root password"
|
||||
required={formData.auth === "password"}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div> */}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Options</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enable_publicip"
|
||||
checked={formData.enable_publicip}
|
||||
onCheckedChange={(checked) => setFormData({...formData, enable_publicip: checked})}
|
||||
/>
|
||||
<Label htmlFor="enable_publicip">Enable Public IP</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enablebackup"
|
||||
checked={formData.enablebackup}
|
||||
onCheckedChange={(checked) => setFormData({...formData, enablebackup: checked})}
|
||||
/>
|
||||
<Label htmlFor="enablebackup">Enable Weekly Backups (+20%)</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={isLoading} className="w-full md:w-auto">
|
||||
{isLoading ? "Deploying..." : "Deploy Instance"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "../ui/select";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import Loader from "../ui/loader";
|
||||
import { useToast } from "../ui/toast";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "../ui/tabs";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
import { Switch } from "../ui/switch";
|
||||
|
||||
export default function NewCloudInstance() {
|
||||
const PUBLIC_UTHO_API_KEY = import.meta.env.PUBLIC_UTHO_API_KEY;
|
||||
const { showToast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetchingData, setIsFetchingData] = useState(true);
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [sshKeys, setSshKeys] = useState([]);
|
||||
const [showAddSshKey, setShowAddSshKey] = useState(false);
|
||||
const [vpcs, setVpcs] = useState();
|
||||
const [images, setImages] = useState([
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Alma Linux",
|
||||
"version": "9.2 x86_64",
|
||||
"image": "almalinux-9.2-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Alma Linux",
|
||||
"version": "8.4 x86_64",
|
||||
"image": "AlmaLinux_8.4_x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Centos",
|
||||
"version": "7.3 x86_64",
|
||||
"image": "centos-7.3-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Centos",
|
||||
"version": "8 x86_64",
|
||||
"image": "centos-8-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Centos",
|
||||
"version": "7.9 x86_64",
|
||||
"image": "centos-7.9-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Centos",
|
||||
"version": "Ameyo x86_64",
|
||||
"image": "AmeyoCentos7",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "9.4 x86_64",
|
||||
"image": "debian-9.4-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "9.13 x86_64",
|
||||
"image": "debian-9.13-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "10 x86_64",
|
||||
"image": "debian-10-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "11 x86_64",
|
||||
"image": "debian-11-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Debian",
|
||||
"version": "12 x86_64",
|
||||
"image": "debian-12-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Fedora",
|
||||
"version": "34 x86_64",
|
||||
"image": "fedora-34-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Fedora",
|
||||
"version": "32 x86_64",
|
||||
"image": "fedora-32-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Fedora",
|
||||
"version": "33 x86_64",
|
||||
"image": "fedora-33-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "FortiOS",
|
||||
"version": "7.6.2 ",
|
||||
"image": "fortios-762",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Rocky Linux",
|
||||
"version": "8.7 x86_64",
|
||||
"image": "rocky-8.7-86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Rocky Linux",
|
||||
"version": "8.8 x86_64",
|
||||
"image": "rocky-8.8-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Rocky Linux",
|
||||
"version": "9.1 x86_64",
|
||||
"image": "rocky-9.1-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Rocky Linux",
|
||||
"version": "9.2 x86_64",
|
||||
"image": "rocky-9.2-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Ubuntu",
|
||||
"version": "20.04 x86_64",
|
||||
"image": "ubuntu-20.04-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Ubuntu",
|
||||
"version": "18.04 x86_64",
|
||||
"image": "ubuntu-18.10-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Ubuntu",
|
||||
"version": "22.04 x86_64",
|
||||
"image": "ubuntu-22.04-x86_64",
|
||||
"cost": 0
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Windows",
|
||||
"version": "Server 2012R2 x86_64",
|
||||
"image": "windows-server2012r2-x86_64",
|
||||
"cost": 1200
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Windows",
|
||||
"version": "Server 2016 x86_64",
|
||||
"image": "windows-2016",
|
||||
"cost": 1200
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Windows",
|
||||
"version": "Server 2019 x86_64",
|
||||
"image": "windows2019-10nov2020",
|
||||
"cost": 1200
|
||||
},
|
||||
{
|
||||
"distro": null,
|
||||
"distribution": "Windows",
|
||||
"version": "Server 2022 x86_64",
|
||||
"image": "windows-2022",
|
||||
"cost": 1200
|
||||
}
|
||||
])
|
||||
const [formData, setFormData] = useState({
|
||||
dcslug: '',
|
||||
planid: '',
|
||||
billingcycle: "hourly",
|
||||
auth: "option2",
|
||||
enable_publicip: '',
|
||||
subnetRequired: '',
|
||||
firewall: "23434645",
|
||||
cpumodel: "intel",
|
||||
enablebackup: '',
|
||||
root_password: '',
|
||||
support: "unmanaged",
|
||||
vpc: '',
|
||||
cloud: [
|
||||
{
|
||||
hostname: ''
|
||||
}
|
||||
],
|
||||
image: '',
|
||||
sshkeys: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
// Fetch plans and SSH keys in parallel
|
||||
const [plansResponse, sshResponse, vpcLists] = await Promise.all([
|
||||
fetch("https://api.utho.com/v2/plans", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||
}),
|
||||
fetch("https://api.utho.com/v2/key", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||
}),
|
||||
fetch('https://api.utho.com/v2/vpc', {
|
||||
headers: {"Authorization" : `Bearer ${PUBLIC_UTHO_API_KEY}`}
|
||||
})
|
||||
]);
|
||||
|
||||
const plansData = await plansResponse.json();
|
||||
const sshData = await sshResponse.json();
|
||||
const vpcsData = await vpcLists.json();
|
||||
|
||||
setPlans(plansData.plans || []);
|
||||
setSshKeys(sshData.key || []);
|
||||
setVpcs(vpcsData.vpc || []);
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to load configuration data",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsFetchingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
dcslug: formData.dcslug,
|
||||
planid: formData.planid,
|
||||
billingcycle: "hourly",
|
||||
auth: "option2",
|
||||
enable_publicip: formData.enable_publicip ? true : false,
|
||||
subnetRequired: false,
|
||||
firewall: "23434645",
|
||||
cpumodel: "intel",
|
||||
enablebackup: formData.enablebackup ? true : false,
|
||||
root_password: formData.rootPassword,
|
||||
support: "unmanaged",
|
||||
vpc: 'c17032c9-3cfd-4028-8f2a-f3f5aa8c2976',
|
||||
cloud: [
|
||||
{
|
||||
hostname: formData.hostname
|
||||
}
|
||||
],
|
||||
image: formData.image,
|
||||
sshkeys: formData.sshkeys
|
||||
};
|
||||
|
||||
// Add authentication based on selected method
|
||||
if (formData.auth === "ssh_key") {
|
||||
payload.sshkeys = formData.sshkeys;
|
||||
} else {
|
||||
payload.password = formData.password;
|
||||
}
|
||||
console.log('payload', payload)
|
||||
const response = await fetch("https://host-api.cs1.hz.siliconpin.com/v1/vps/?query=deploy&source=chanel_1", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === "success") {
|
||||
showToast({
|
||||
title: "Deployment Started",
|
||||
description: `Server ${data.cloudid} is being deployed with IP ${data.ipv4}`,
|
||||
variant: "success"
|
||||
});
|
||||
// Reset form after successful deployment
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
hostname: "",
|
||||
planid: ""
|
||||
}));
|
||||
} else {
|
||||
throw new Error(data.message || "Failed to deploy instance");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Deployment Failed",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddSshKey = async () => {
|
||||
try {
|
||||
const response = await fetch('https://api.utho.com/v2/key/import', {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: formData.newSshKeyName,
|
||||
sshkey: formData.newSshKeyValue
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
// Refresh SSH keys list
|
||||
const sshResponse = await fetch("https://api.utho.com/v2/key", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_UTHO_API_KEY}` }
|
||||
});
|
||||
const sshData = await sshResponse.json();
|
||||
setSshKeys(sshData.key || []);
|
||||
|
||||
showToast({
|
||||
title: "SSH Key Added",
|
||||
description: "Your SSH key has been successfully added",
|
||||
variant: "success"
|
||||
});
|
||||
|
||||
setShowAddSshKey(false);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to add SSH key",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isFetchingData) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-2xl mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Deploy New Cloud Instance</CardTitle>
|
||||
<CardDescription>Configure your cloud server with essential parameters</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hostname">Hostname *</Label>
|
||||
<Input
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
value={formData.hostname}
|
||||
onChange={handleChange}
|
||||
placeholder="server1.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dcslug">Data Center *</Label>
|
||||
<Select
|
||||
name="dcslug"
|
||||
value={formData.dcslug}
|
||||
onValueChange={(value) => setFormData({...formData, dcslug: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select location" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inmumbaizone2">Mumbai</SelectItem>
|
||||
<SelectItem value="inbangalore">Bangalore</SelectItem>
|
||||
<SelectItem value="innoida">Delhi (Noida)</SelectItem>
|
||||
<SelectItem value="defra1">Germany</SelectItem>
|
||||
<SelectItem value="uslosangeles">Los Angeles</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="planid">Plan *</Label>
|
||||
<Select
|
||||
name="planid"
|
||||
value={formData.planid}
|
||||
onValueChange={(value) => setFormData({...formData, planid: value})}
|
||||
required
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map(plan => (
|
||||
<SelectItem key={plan.id} value={plan.id}>
|
||||
{`${plan.cpu} vCPU, ${plan.ram}MB RAM - ${plan.price_cur}/mo`}
|
||||
</SelectItem>
|
||||
))}
|
||||
{/* <SelectItem key={plans[0].id} value={plans[0].id}>
|
||||
{`${plans[0].cpu} vCPU, ${plans[0].ram}MB RAM - ${plans[0].price_cur}/mo`}
|
||||
</SelectItem> */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image">Operating System *</Label>
|
||||
<Select
|
||||
name="image"
|
||||
value={formData.image}
|
||||
onValueChange={(value) => setFormData({...formData, image: value})}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select OS" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{
|
||||
images.map((image, i) => (
|
||||
<SelectItem key={i} value={image.image}>{image.image.split('-x86_64')[0]}</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpc">VPC *</Label>
|
||||
<Select
|
||||
name="vpc"
|
||||
value={formData.vpc}
|
||||
onValueChange={(value) => setFormData({...formData, vpc: value})}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select VPC" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{
|
||||
vpcs.map((vpc, n) => (
|
||||
<SelectItem key={n} value={`${vpc.id}`}>{vpc.dclocation.location}</SelectItem>
|
||||
))
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Authentication</h3>
|
||||
<Tabs
|
||||
value={formData.auth}
|
||||
onValueChange={(value) => setFormData({...formData, auth: value})}
|
||||
className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 gap-4">
|
||||
<TabsTrigger className={`p-2 rounded-md ${formData.auth === 'ssh_key' ? 'bg-[#6d9e37] text-[#FFF]' : 'border border-[#6d9e37]'}`} value="ssh_key">SSH Key</TabsTrigger>
|
||||
<TabsTrigger className={`p-2 rounded-md ${formData.auth === 'password' ? 'bg-[#6d9e37] text-[#FFF]' : 'border border-[#6d9e37]'}`} value="password">Password</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ssh_key" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Select SSH Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formData.sshkeys}
|
||||
onValueChange={(value) => setFormData({...formData, sshkeys: value})}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select SSH key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sshKeys.map(key => (
|
||||
<SelectItem key={key.id} value={key.id}>{key.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setShowAddSshKey(!showAddSshKey)}
|
||||
>
|
||||
{showAddSshKey ? "Cancel" : "Add New"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddSshKey && (
|
||||
<div className="space-y-4 p-4 border rounded-lg">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newSshKeyName">Key Name</Label>
|
||||
<Input
|
||||
id="newSshKeyName"
|
||||
name="newSshKeyName"
|
||||
value={formData.newSshKeyName}
|
||||
onChange={handleChange}
|
||||
placeholder="My Laptop Key"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newSshKeyValue">Public Key</Label>
|
||||
<Textarea
|
||||
id="newSshKeyValue"
|
||||
name="newSshKeyValue"
|
||||
value={formData.newSshKeyValue}
|
||||
onChange={handleChange}
|
||||
placeholder="ssh-rsa AAAAB3NzaC1yc2E..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddSshKey}
|
||||
disabled={!formData.newSshKeyName || !formData.newSshKeyValue}
|
||||
>
|
||||
Add SSH Key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="password" className="space-y-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Root Password *</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="Enter root password"
|
||||
required={formData.auth === "password"}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">Options</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enable_publicip"
|
||||
checked={formData.enable_publicip}
|
||||
onCheckedChange={(checked) => setFormData({...formData, enable_publicip: checked})}
|
||||
/>
|
||||
<Label htmlFor="enable_publicip">Enable Public IP</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="enablebackup"
|
||||
checked={formData.enablebackup}
|
||||
onCheckedChange={(checked) => setFormData({...formData, enablebackup: checked})}
|
||||
/>
|
||||
<Label htmlFor="enablebackup">Enable Weekly Backups (+20%)</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button type="submit" disabled={isLoading} className="w-full md:w-auto">
|
||||
{isLoading ? "Deploying..." : "Deploy Instance"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
114
src/components/BuyServices/STTStreaming.jsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
import Loader from "../ui/loader";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
const PRICE_CONFIG = [
|
||||
{purchaseType: 'loose', price: 2000, minute: 10000},
|
||||
{purchaseType: 'bulk', price: 1000, minute: 10000}
|
||||
];
|
||||
export default function STTStreaming(){
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
const [purchaseType, setPurchaseType] = useState();
|
||||
const [amount, setAmount] = useState();
|
||||
const [dataError, setDataError] = useState();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const handlePurchaseType = (type) => {
|
||||
const selected = PRICE_CONFIG.find(item => item.purchaseType === type);
|
||||
setAmount(type === 'bulk' ? selected.price : type === 'loose' ? selected.price : '')
|
||||
setPurchaseType(type);
|
||||
}
|
||||
|
||||
const handlePurchase = async () => {
|
||||
// Validate inputs
|
||||
if (!purchaseType) {
|
||||
setDataError('Please select a Plan');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setDataError(null);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('service', 'STT Streaming API');
|
||||
formData.append('serviceId', 'sttstreamingapi');
|
||||
formData.append('cycle', 'monthly');
|
||||
formData.append('amount', amount.toString());
|
||||
formData.append('service_type', 'streaming_api');
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=initiate_payment`, {
|
||||
method: 'POST',
|
||||
body: formData, // Using FormData instead of JSON
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Redirect to payment success page with order ID
|
||||
window.location.href = `/success?service=streaming_api&orderId=${data.order_id}`;
|
||||
} else {
|
||||
throw new Error(data.message || 'Payment initialization failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Purchase error:', error);
|
||||
setDataError(error.message || 'An error occurred during purchase. Please try again.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
if(!purchaseType){
|
||||
<div>
|
||||
<p>{dataError}</p>
|
||||
</div>
|
||||
}
|
||||
return(
|
||||
<>
|
||||
<div className='container mx-auto'>
|
||||
<Card className='max-w-2xl mx-auto px-4 mt-8'>
|
||||
<CardContent>
|
||||
<CardHeader>
|
||||
<CardTitle>STT Streaming API</CardTitle>
|
||||
<CardDescription>Real-time Speech-to-Text (STT) streaming that converts spoken words into accurate, readable text</CardDescription>
|
||||
</CardHeader>
|
||||
<div className='flex flex-row justify-between items-center gap-x-6'>
|
||||
<label htmlFor="purchase-type-loose" className={`border ${purchaseType === 'loose' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer ${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||
<input onChange={() => handlePurchaseType('loose')} type="radio" name="purchase-type" id="purchase-type-loose" className='hidden' />
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-2xl font-bold'>Loose Plan</span>
|
||||
<span className=''>🕐10,000 Min ₹2000</span>
|
||||
</div>
|
||||
</label>
|
||||
<label htmlFor="purchase-type-bulk" className={`border ${purchaseType === 'bulk' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer ${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}`}>
|
||||
<input onChange={() => handlePurchaseType('bulk')} type="radio" name="purchase-type" id="purchase-type-bulk" className='hidden' />
|
||||
<div className='flex flex-col'>
|
||||
<span className='text-2xl font-bold'>Bulk Plan</span>
|
||||
<span className=''>🕐10,000 Min ₹1000</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 justify-between mt-2 gap-x-2 text-xs">
|
||||
{['Setup Charge 5000', '10000 Min INR 2000 in Loose Plan', '10000 Min INR 1000 in Bulk Plan', 'Real-time transcription with high accuracy', 'Supports multiple languages', 'Custom vocabulary for domain-specific terms', 'Integrates easily with video/audio streams', 'Secure and scalable API access', 'Live subtitle overlay options'].map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[#6d9e37] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className='text-zinc-400'>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className='flex my-8 w-full'>
|
||||
<Button onClick={handlePurchase} disabled={!purchaseType} className={`w-full ${!purchaseType ? 'cursor-not-allowed' : ''}`}>{isProcessing ? (<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Processing...</>) : 'Proceed'}</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
43
src/components/BuyServices/uthoPayload.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
const payload = {
|
||||
"dcslug": "inbangalore",
|
||||
"planid": "10045",
|
||||
"billingcycle": "hourly",
|
||||
"auth": "option2",
|
||||
"enable_publicip": "true",
|
||||
"subnetRequired": "false",
|
||||
"firewall": "23434645",
|
||||
"cpumodel": "intel",
|
||||
"enablebackup": "false",
|
||||
"root_password": "5qT381g@K@pDsq",
|
||||
"support": "unmanaged",
|
||||
"vpc": "c17032c9-3cfd-4028-8f2a-f3f5aa8c2976",
|
||||
"cloud": [
|
||||
{
|
||||
"hostname": "cloudserver-ep0yNFPI.mhc"
|
||||
}
|
||||
],
|
||||
"image": "debian-9.4-x86_64",
|
||||
"sshkeys": "74131237"
|
||||
};
|
||||
|
||||
// const payload = {
|
||||
// dcslug: formData.dcslug,
|
||||
// planid: formData.planid,
|
||||
// billingcycle: "hourly",
|
||||
// auth: "option2",
|
||||
// enable_publicip: formData.enable_publicip ? true : false,
|
||||
// subnetRequired: false,
|
||||
// firewall: "23434645",
|
||||
// cpumodel: "intel",
|
||||
// enablebackup: formData.enablebackup ? true : false,
|
||||
// root_password: formData.rootPassword,
|
||||
// support: "unmanaged",
|
||||
// vpc: formData.vpc,
|
||||
// cloud: [
|
||||
// {
|
||||
// hostname: formData.hostname
|
||||
// }
|
||||
// ],
|
||||
// image: "debian-9.4-x86_64",
|
||||
// sshkeys: formData.sshkeys
|
||||
// };
|
||||
200
src/components/Comment copy.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card } from "./ui/card";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Button } from "./ui/button";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
|
||||
const COMMENTS_API_URL = 'https://host-api-sxashuasysagibx.siliconpin.com/v1/comments/';
|
||||
|
||||
export default function Comment(props) {
|
||||
const [comments, setComments] = useState([]);
|
||||
const [newComment, setNewComment] = useState({ comment: '' });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [isLoadingComments, setIsLoadingComments] = useState(true);
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
|
||||
// Load comments when component mounts or when topicId changes
|
||||
useEffect(() => {
|
||||
if (props.topicId) {
|
||||
fetchComments(props.topicId);
|
||||
}
|
||||
}, [props.topicId]);
|
||||
|
||||
const fetchComments = async (topicId) => {
|
||||
setIsLoadingComments(true);
|
||||
try {
|
||||
const response = await fetch(`${COMMENTS_API_URL}?topicId=${topicId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to fetch comments');
|
||||
}
|
||||
|
||||
if (data.success && data.comments) {
|
||||
setComments(data.comments);
|
||||
} else {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching comments:', error);
|
||||
setSubmitError('Failed to load comments');
|
||||
} finally {
|
||||
setIsLoadingComments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewComment(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmitComment = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setSubmitError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${COMMENTS_API_URL}?query=new-comment`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...newComment,
|
||||
topicId: props.topicId
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNewComment({ comment: '' });
|
||||
setSubmitSuccess(true);
|
||||
setTimeout(() => setSubmitSuccess(false), 3000);
|
||||
|
||||
// Refresh comments after successful submission
|
||||
await fetchComments(props.topicId);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setSubmitError(errorData.message || 'Failed to submit comment');
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitError('Network error. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name) => {
|
||||
if (!name) return 'U';
|
||||
const words = name.trim().split(' ');
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map(word => word[0].toUpperCase())
|
||||
.join('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Comments List */}
|
||||
<div className="space-y-6 border-t">
|
||||
<h2 className="text-2xl font-bold text-[#6d9e37]">Comments ({comments.length})</h2>
|
||||
|
||||
{isLoadingComments ? (
|
||||
<div className="flex justify-center py-4">
|
||||
{/* <div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-[#6d9e37]"></div> */}
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-gray-500">No comments yet. Be the first to comment!</p>
|
||||
) : (
|
||||
comments.map(comment => (
|
||||
<div key={comment.id} className="border-b pb-6 last:border-b-0">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 font-bold">
|
||||
{getUserInitials(comment.userName)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-[#6d9e37]">
|
||||
{comment.userName || 'User'}
|
||||
</h4>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(comment.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="">{comment.comment}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<Card className="mt-16 border-t">
|
||||
<div className="relative">
|
||||
<div className="px-6 pb-6 rounded-lg">
|
||||
<h3 className="text-lg font-medium mb-4 pt-8">Leave a Comment</h3>
|
||||
{submitSuccess && (
|
||||
<div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
|
||||
Thank you for your comment!
|
||||
</div>
|
||||
)}
|
||||
{submitError && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmitComment}>
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="comment">Comment *</Label>
|
||||
<Textarea
|
||||
className="mt-2"
|
||||
id="comment"
|
||||
name="comment"
|
||||
rows="4"
|
||||
value={newComment.comment}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
disabled={!isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting || !isLoggedIn}>
|
||||
{isSubmitting ? 'Submitting...' : 'Post Comment'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-full h-full absolute bg-black inset-0 opacity-70 backdrop-blur-2xl rounded-lg" />
|
||||
<div className="w-10 h-10 rounded-full border-2 border-dotted border-[#6d9e37] absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</>
|
||||
) : !isLoggedIn ? (
|
||||
<>
|
||||
<div className="w-full h-full absolute bg-black inset-0 opacity-70 backdrop-blur-2xl rounded-lg" />
|
||||
<p className="text-gray-100 bg-gray-700 p-2 rounded-md shadow-xl italic absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
Join the conversation! Log in or sign up to post a comment
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
260
src/components/CommentSystem/CommentSystem.jsx
Normal file
@@ -0,0 +1,260 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import EmojiPicker from 'emoji-picker-react';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import CommentThread from './CommentThread';
|
||||
import { token, user_name, pb_id, siliconId, isLogin } from '../../lib/CookieValues';
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
|
||||
const CommentSystem = ({ topicId, pbId }) => {
|
||||
const isLoginCoockie = JSON.parse(isLogin);
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
const [comments, setComments] = useState([]);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
const [commentsLoading, setCommentsLoading] = useState(false);
|
||||
const [commentsError, setCommentsError] = useState('');
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
const textareaRef = useRef(null);
|
||||
const emojiPickerRef = useRef(null);
|
||||
|
||||
// 📌 Insert emoji at cursor position
|
||||
const insertEmojiAtCursor = (emoji) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = newComment;
|
||||
|
||||
const updatedText = text.slice(0, start) + emoji + text.slice(end);
|
||||
setNewComment(updatedText);
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 📌 Outside click to close emoji picker
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (
|
||||
emojiPickerRef.current &&
|
||||
!emojiPickerRef.current.contains(e.target) &&
|
||||
!e.target.closest('#emoji-toggle-btn')
|
||||
) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// 📌 Comment nesting logic
|
||||
const nestComments = (flatComments) => {
|
||||
const commentMap = {};
|
||||
const rootComments = [];
|
||||
|
||||
flatComments?.forEach(comment => {
|
||||
comment.replies = [];
|
||||
commentMap[comment.comment_id] = comment;
|
||||
});
|
||||
|
||||
flatComments?.forEach(comment => {
|
||||
if (comment.parent_comment_id?.Valid) {
|
||||
const parent = commentMap[comment.parent_comment_id.String];
|
||||
if (parent) parent.replies.push(comment);
|
||||
} else {
|
||||
rootComments.push(comment);
|
||||
}
|
||||
});
|
||||
|
||||
return rootComments.sort((a, b) =>
|
||||
new Date(b.created_at) - new Date(a.created_at));
|
||||
};
|
||||
|
||||
const fetchComments = async () => {
|
||||
try {
|
||||
setCommentsLoading(true);
|
||||
setCommentsError('');
|
||||
|
||||
const params = new URLSearchParams({ topic_id: String(topicId) });
|
||||
|
||||
if (siliconId && siliconId !== 'null') {
|
||||
params.append('silicon_id', siliconId);
|
||||
}
|
||||
|
||||
if (pb_id && pb_id !== 'null') {
|
||||
params.append('pb_id', pb_id);
|
||||
}
|
||||
|
||||
const response = await fetch(`https://sp-comments-prod-pocndhvgmcnbacgb.siliconpin.com/comments?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const flatComments = await response.json();
|
||||
setComments(nestComments(flatComments || []));
|
||||
} catch (err) {
|
||||
setCommentsError(err.message);
|
||||
} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const postComment = async (text, parentId = null) => {
|
||||
try {
|
||||
setCommentsLoading(true);
|
||||
|
||||
const payload = {
|
||||
topic_id: String(topicId),
|
||||
silicon_id: siliconId,
|
||||
pb_id: pbId,
|
||||
user_id: siliconId,
|
||||
user_name: user_name,
|
||||
comment_text: text,
|
||||
is_approved: true,
|
||||
};
|
||||
|
||||
if (parentId) {
|
||||
payload.parent_comment_id = parentId;
|
||||
}
|
||||
|
||||
const endpoint = parentId ? '/comments/reply' : '/comments';
|
||||
|
||||
const response = await fetch(`https://sp-comments-prod-pocndhvgmcnbacgb.siliconpin.com${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || 'Failed to post comment');
|
||||
}
|
||||
|
||||
await fetchComments();
|
||||
} catch (err) {
|
||||
setCommentsError(err.message);
|
||||
} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments();
|
||||
}, [topicId, siliconId, pbId]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-2xl font-bold text-[#6d9e37] mb-6">Comments</h2>
|
||||
|
||||
{commentsError && (
|
||||
<div className="bg-red-50 text-red-700 p-3 rounded-md mb-4">
|
||||
{commentsError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{commentsLoading && comments.length === 0 ? (
|
||||
<div className="text-center py-8 text-[#6d9e37]">Loading comments...</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{comments.length > 0 ? (
|
||||
comments.map(comment => (
|
||||
<CommentThread key={comment.comment_id} comment={comment} onReply={postComment} />
|
||||
))
|
||||
) : (
|
||||
<p className="text-center py-8 text-gray-500 italic">
|
||||
No comments yet. Be the first to comment!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{isLoginCoockie !== true ? (
|
||||
<div className="absolute bg-black bg-opacity-70 w-full h-full rounded-lg">
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
<p className="italic bg-[#2a2e35] px-2 py-1.5 rounded-md shadow-xl">
|
||||
To join the conversation, please log in first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Card className="mt-8 p-6 shadow-md border border-gray-200">
|
||||
<h3 className="text-lg font-medium mb-4">Add a Comment</h3>
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!newComment.trim()) return;
|
||||
postComment(newComment);
|
||||
setNewComment('');
|
||||
}}>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Share your thoughts..."
|
||||
className="min-h-[100px] pr-12 border rounded-md"
|
||||
required
|
||||
minLength={1}
|
||||
/>
|
||||
|
||||
{/* Emoji Button */}
|
||||
<button
|
||||
id="emoji-toggle-btn"
|
||||
type="button"
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
className="absolute bottom-3 right-3 text-xl text-gray-500 hover:text-yellow-500 transition-colors"
|
||||
title="Add Emoji"
|
||||
>
|
||||
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M8.5 11C9.32843 11 10 10.3284 10 9.5C10 8.67157 9.32843 8 8.5 8C7.67157 8 7 8.67157 7 9.5C7 10.3284 7.67157 11 8.5 11Z" fill="#6d9e37"></path> <path d="M17 9.5C17 10.3284 16.3284 11 15.5 11C14.6716 11 14 10.3284 14 9.5C14 8.67157 14.6716 8 15.5 8C16.3284 8 17 8.67157 17 9.5Z" fill="#6d9e37"></path> <path d="M8.88875 13.5414C8.63822 13.0559 8.0431 12.8607 7.55301 13.1058C7.05903 13.3528 6.8588 13.9535 7.10579 14.4474C7.18825 14.6118 7.29326 14.7659 7.40334 14.9127C7.58615 15.1565 7.8621 15.4704 8.25052 15.7811C9.04005 16.4127 10.2573 17.0002 12.0002 17.0002C13.7431 17.0002 14.9604 16.4127 15.7499 15.7811C16.1383 15.4704 16.4143 15.1565 16.5971 14.9127C16.7076 14.7654 16.8081 14.6113 16.8941 14.4485C17.1387 13.961 16.9352 13.3497 16.4474 13.1058C15.9573 12.8607 15.3622 13.0559 15.1117 13.5414C15.0979 13.5663 14.9097 13.892 14.5005 14.2194C14.0401 14.5877 13.2573 15.0002 12.0002 15.0002C10.7431 15.0002 9.96038 14.5877 9.49991 14.2194C9.09071 13.892 8.90255 13.5663 8.88875 13.5414Z" fill="#6d9e37"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 20.9932C7.03321 20.9932 3.00683 16.9668 3.00683 12C3.00683 7.03321 7.03321 3.00683 12 3.00683C16.9668 3.00683 20.9932 7.03321 20.9932 12C20.9932 16.9668 16.9668 20.9932 12 20.9932Z" fill="#6d9e37"></path> </g></svg>
|
||||
</button>
|
||||
|
||||
{/* Emoji Picker */}
|
||||
{showEmojiPicker && (
|
||||
<div
|
||||
ref={emojiPickerRef}
|
||||
className="absolute bottom-16 right-0 z-50 rounded-lg shadow-lg"
|
||||
>
|
||||
<EmojiPicker
|
||||
onEmojiClick={(emojiData) => insertEmojiAtCursor(emojiData.emoji)}
|
||||
height={400}
|
||||
width={300}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={commentsLoading || !newComment.trim()}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{commentsLoading ? (
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Posting...
|
||||
</span>
|
||||
) : 'Post Comment'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentSystem;
|
||||
157
src/components/CommentSystem/CommentThread.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import EmojiPicker from 'emoji-picker-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { token, user_name, pb_id, siliconId, isLogin } from '../../lib/CookieValues';
|
||||
|
||||
const CommentThread = ({ comment, depth = 0, onReply }) => {
|
||||
const isLoginCoockie = JSON.parse(isLogin);
|
||||
const [isReplying, setIsReplying] = useState(false);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
|
||||
const textareaRef = useRef(null);
|
||||
const emojiPickerRef = useRef(null);
|
||||
|
||||
// 🧠 Emoji insert at cursor
|
||||
const insertEmojiAtCursor = (emoji) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = replyText;
|
||||
|
||||
const updatedText = text.slice(0, start) + emoji + text.slice(end);
|
||||
setReplyText(updatedText);
|
||||
|
||||
setTimeout(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + emoji.length, start + emoji.length);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 🧠 Close emoji picker on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (
|
||||
emojiPickerRef.current &&
|
||||
!emojiPickerRef.current.contains(e.target) &&
|
||||
!e.target.closest(`#reply-emoji-btn-${comment.comment_id}`)
|
||||
) {
|
||||
setShowEmojiPicker(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [comment.comment_id]);
|
||||
|
||||
const handleReplySubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!replyText.trim()) return;
|
||||
onReply(replyText, comment.comment_id);
|
||||
setReplyText('');
|
||||
setIsReplying(false);
|
||||
setShowEmojiPicker(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mb-4 pl-${depth * 4} border-l-2 ${depth > 0 ? 'border-[#6d9e37]' : 'border-transparent'}`}
|
||||
style={{ marginLeft: `${depth * 0.5}rem` }}
|
||||
>
|
||||
<div className="bg-[#25272950] rounded-lg shadow-sm px-4 py-3 mb-2">
|
||||
<div className="flex gap-3 items-center justify-center">
|
||||
<div className="flex-shrink-0">
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" className="rounded-full bg-[#6d9e37] p-1"><path d="M20 20C24.1421 20 27.5 16.6421 27.5 12.5C27.5 8.35786 24.1421 5 20 5C15.8579 5 12.5 8.35786 12.5 12.5C12.5 16.6421 15.8579 20 20 20ZM20 23.75C14.2675 23.75 5 26.4825 5 32.1875V35H35V32.1875C35 26.4825 25.7325 23.75 20 23.75Z" fill="white"/></svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className="font-medium text-[#6d9e37]">{comment.user_name}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{new Date(comment.created_at).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-100 mb-2 whitespace-pre-wrap">{comment.comment_text}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoginCoockie === true && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-none text-[#6d9e37] hover:text-[#8bc34a] hover:bg-transparent px-0 py-0 h-auto"
|
||||
onClick={() => setIsReplying(!isReplying)}
|
||||
>
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isReplying && (
|
||||
<form onSubmit={handleReplySubmit} className="mt-3 pl-11 relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Write your reply..."
|
||||
className="mb-2 min-h-[70px] pr-10"
|
||||
required
|
||||
minLength={1}
|
||||
/>
|
||||
|
||||
{/* Emoji Button */}
|
||||
<button
|
||||
type="button"
|
||||
id={`reply-emoji-btn-${comment.comment_id}`}
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
className="absolute bottom-4 right-4 text-xl text-gray-400 hover:text-yellow-500 transition"
|
||||
title="Add Emoji"
|
||||
>
|
||||
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M8.5 11C9.32843 11 10 10.3284 10 9.5C10 8.67157 9.32843 8 8.5 8C7.67157 8 7 8.67157 7 9.5C7 10.3284 7.67157 11 8.5 11Z" fill="#6d9e37"></path> <path d="M17 9.5C17 10.3284 16.3284 11 15.5 11C14.6716 11 14 10.3284 14 9.5C14 8.67157 14.6716 8 15.5 8C16.3284 8 17 8.67157 17 9.5Z" fill="#6d9e37"></path> <path d="M8.88875 13.5414C8.63822 13.0559 8.0431 12.8607 7.55301 13.1058C7.05903 13.3528 6.8588 13.9535 7.10579 14.4474C7.18825 14.6118 7.29326 14.7659 7.40334 14.9127C7.58615 15.1565 7.8621 15.4704 8.25052 15.7811C9.04005 16.4127 10.2573 17.0002 12.0002 17.0002C13.7431 17.0002 14.9604 16.4127 15.7499 15.7811C16.1383 15.4704 16.4143 15.1565 16.5971 14.9127C16.7076 14.7654 16.8081 14.6113 16.8941 14.4485C17.1387 13.961 16.9352 13.3497 16.4474 13.1058C15.9573 12.8607 15.3622 13.0559 15.1117 13.5414C15.0979 13.5663 14.9097 13.892 14.5005 14.2194C14.0401 14.5877 13.2573 15.0002 12.0002 15.0002C10.7431 15.0002 9.96038 14.5877 9.49991 14.2194C9.09071 13.892 8.90255 13.5663 8.88875 13.5414Z" fill="#6d9e37"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM12 20.9932C7.03321 20.9932 3.00683 16.9668 3.00683 12C3.00683 7.03321 7.03321 3.00683 12 3.00683C16.9668 3.00683 20.9932 7.03321 20.9932 12C20.9932 16.9668 16.9668 20.9932 12 20.9932Z" fill="#6d9e37"></path> </g></svg>
|
||||
</button>
|
||||
|
||||
{/* Emoji Picker */}
|
||||
{showEmojiPicker && (
|
||||
<div
|
||||
ref={emojiPickerRef}
|
||||
className="absolute bottom-20 right-0 z-50 rounded-lg shadow-lg"
|
||||
>
|
||||
<EmojiPicker
|
||||
onEmojiClick={(emojiData) => insertEmojiAtCursor(emojiData.emoji)}
|
||||
height={400}
|
||||
width={300}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button type="submit" size="sm" className="bg-[#6d9e37] hover:bg-[#8bc34a]">
|
||||
Post Reply
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-gray-600 text-gray-300 hover:border-[#6d9e37] hover:text-[#8bc34a]"
|
||||
onClick={() => {
|
||||
setIsReplying(false);
|
||||
setShowEmojiPicker(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{comment.replies?.map((reply) => (
|
||||
<CommentThread key={reply.comment_id} comment={reply} depth={depth + 1} onReply={onReply} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentThread;
|
||||
98
src/components/CommentSystem/LikeSystem.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Heart, Loader2 } from 'lucide-react';
|
||||
|
||||
export default function LikeSystem({ postId, siliconId, token }) {
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLikeStatusAndCount = async () => {
|
||||
try {
|
||||
const countRes = await fetch(`https://sp-likes-pocndhvgmcnbacgb.siliconpin.com/posts/${postId}/likes`);
|
||||
const countData = await countRes.json();
|
||||
setLikeCount(countData.likes);
|
||||
|
||||
const likedRes = await fetch(
|
||||
`https://sp-likes-pocndhvgmcnbacgb.siliconpin.com/is-liked?post_id=${postId}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-SiliconId': siliconId,
|
||||
},
|
||||
}
|
||||
);
|
||||
const likedData = await likedRes.json();
|
||||
setLiked(likedData.liked);
|
||||
} catch (err) {
|
||||
console.error("Error fetching like data", err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLikeStatusAndCount();
|
||||
}, [postId, token, siliconId]);
|
||||
|
||||
const handleLikeToggle = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`https://sp-likes-pocndhvgmcnbacgb.siliconpin.com/like`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-SiliconId': siliconId,
|
||||
},
|
||||
body: JSON.stringify({ post_id: postId }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setLiked(data.liked);
|
||||
setLikeCount(prev => data.liked ? prev + 1 : prev - 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Network error:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
onClick={handleLikeToggle}
|
||||
disabled={loading}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px',
|
||||
padding: '6px 14px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={22} className="animate-spin" />
|
||||
) : (
|
||||
<Heart
|
||||
size={28}
|
||||
fill={liked ? '#ff4d4f' : 'none'}
|
||||
color={liked ? '#ff4d4f' : '#999'}
|
||||
strokeWidth={2.2}
|
||||
style={{ transition: '0.2s ease-in-out' }}
|
||||
/>
|
||||
)}
|
||||
<span style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 'bold',
|
||||
color: liked ? '#ff4d4f' : '#555'
|
||||
}}>
|
||||
{likeCount}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/components/CommentSystem/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import CommentSystem from './CommentSystem';
|
||||
import CommentThread from './CommentThread';
|
||||
|
||||
export default CommentSystem;
|
||||
export { CommentThread };
|
||||
371
src/components/CommentWithMD.jsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||||
import { Card } from "./ui/card";
|
||||
import { Label } from "./ui/label";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
|
||||
const COMMENTS_API_URL = 'https://host-api-sxashuasysagibx.siliconpin.com/v1/comments/';
|
||||
const PUBLIC_MINIO_UPLOAD_URL = import.meta.env.PUBLIC_MINIO_UPLOAD_URL;
|
||||
|
||||
export default function Comment(props) {
|
||||
const [comments, setComments] = useState([]);
|
||||
const [newComment, setNewComment] = useState({ comment: '' });
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitSuccess, setSubmitSuccess] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [isLoadingComments, setIsLoadingComments] = useState(true);
|
||||
const [editorMode, setEditorMode] = useState('edit');
|
||||
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||
const [imageUrlInput, setImageUrlInput] = useState('');
|
||||
const [imageUploadFile, setImageUploadFile] = useState(null);
|
||||
const [imageUploadPreview, setImageUploadPreview] = useState('');
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
|
||||
// Custom image command for MDEditor
|
||||
const customImageCommand = {
|
||||
name: 'image',
|
||||
keyCommand: 'image',
|
||||
buttonProps: { 'aria-label': 'Insert image' },
|
||||
icon: (
|
||||
<svg width="12" height="12" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M15 9c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4-7H1c-.55 0-1 .45-1 1v14c0 .55.45 1 1 1h18c.55 0 1-.45 1-1V3c0-.55-.45-1-1-1zm-1 13l-6-5-2 2-4-5-4 8V4h16v11z"/>
|
||||
</svg>
|
||||
),
|
||||
execute: () => {
|
||||
setImageDialogOpen(true);
|
||||
},
|
||||
};
|
||||
|
||||
// Get all default commands and replace the image command
|
||||
const allCommands = commands.getCommands().map(cmd => {
|
||||
if (cmd.name === 'image') {
|
||||
return customImageCommand;
|
||||
}
|
||||
return cmd;
|
||||
});
|
||||
|
||||
// Load comments when component mounts
|
||||
useEffect(() => {
|
||||
if (props.topicId) {
|
||||
fetchComments(props.topicId);
|
||||
}
|
||||
}, [props.topicId]);
|
||||
|
||||
const fetchComments = async (topicId) => {
|
||||
setIsLoadingComments(true);
|
||||
try {
|
||||
const response = await fetch(`${COMMENTS_API_URL}?topicId=${topicId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Failed to fetch comments');
|
||||
}
|
||||
|
||||
if (data.success && data.comments) {
|
||||
setComments(data.comments);
|
||||
} else {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching comments:', error);
|
||||
setSubmitError('Failed to load comments');
|
||||
} finally {
|
||||
setIsLoadingComments(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload file to MinIO
|
||||
const uploadToMinIO = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('api_key', 'wweifwehfwfhwhtuyegbvijvbfvegfreyf');
|
||||
|
||||
try {
|
||||
const response = await fetch(PUBLIC_MINIO_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.publicUrl;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle image URL insertion
|
||||
const handleInsertImageUrl = () => {
|
||||
if (imageUrlInput) {
|
||||
const imgMarkdown = ``;
|
||||
setNewComment(prev => ({
|
||||
...prev,
|
||||
comment: prev.comment ? `${prev.comment}\n${imgMarkdown}` : imgMarkdown
|
||||
}));
|
||||
setImageDialogOpen(false);
|
||||
setImageUrlInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle image file selection
|
||||
const handleImageFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setImageUploadFile(file);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImageUploadPreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload image file to MinIO and insert into editor
|
||||
const handleImageUpload = async () => {
|
||||
if (!imageUploadFile) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
const uploadedUrl = await uploadToMinIO(imageUploadFile);
|
||||
|
||||
// Insert markdown for the uploaded image
|
||||
const imgMarkdown = ``;
|
||||
setNewComment(prev => ({
|
||||
...prev,
|
||||
comment: prev.comment ? `${prev.comment}\n${imgMarkdown}` : imgMarkdown
|
||||
}));
|
||||
|
||||
setImageDialogOpen(false);
|
||||
setImageUploadFile(null);
|
||||
setImageUploadPreview('');
|
||||
} catch (error) {
|
||||
setSubmitError('Failed to upload image: ' + error.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitComment = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setSubmitError('');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${COMMENTS_API_URL}?query=new-comment`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...newComment,
|
||||
topicId: props.topicId
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNewComment({ comment: '' });
|
||||
setSubmitSuccess(true);
|
||||
setTimeout(() => setSubmitSuccess(false), 3000);
|
||||
|
||||
// Refresh comments after successful submission
|
||||
await fetchComments(props.topicId);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setSubmitError(errorData.message || 'Failed to submit comment');
|
||||
}
|
||||
} catch (error) {
|
||||
setSubmitError('Network error. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUserInitials = (name) => {
|
||||
if (!name) return 'U';
|
||||
const words = name.trim().split(' ');
|
||||
return words
|
||||
.slice(0, 2)
|
||||
.map(word => word[0].toUpperCase())
|
||||
.join('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Comments List */}
|
||||
<div className="space-y-6 border-t">
|
||||
<h2 className="text-2xl font-bold text-[#6d9e37]">Comments ({comments.length})</h2>
|
||||
|
||||
{isLoadingComments ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-[#6d9e37]"></div>
|
||||
</div>
|
||||
) : comments.length === 0 ? (
|
||||
<p className="text-gray-500">No comments yet. Be the first to comment!</p>
|
||||
) : (
|
||||
comments.map(comment => (
|
||||
<div key={comment.id} className="border-b pb-6 last:border-b-0">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-500 font-bold">
|
||||
{getUserInitials(comment.userName)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-[#6d9e37]">
|
||||
{comment.userName || 'User'}
|
||||
</h4>
|
||||
<span className="text-xs text-gray-500">
|
||||
{new Date(comment.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div data-color-mode="light" className="markdown-body">
|
||||
<MDEditor.Markdown source={comment.comment} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Comments Section with MDEditor */}
|
||||
<Card className="mt-16 border-t">
|
||||
<div className="relative">
|
||||
<div className="px-6 pb-6 rounded-lg">
|
||||
<h3 className="text-lg font-medium mb-4 pt-8">Leave a Comment</h3>
|
||||
{submitSuccess && (
|
||||
<div className="mb-4 p-3 bg-green-100 text-green-700 rounded">
|
||||
Thank you for your comment!
|
||||
</div>
|
||||
)}
|
||||
{submitError && (
|
||||
<div className="mb-4 p-3 bg-red-100 text-red-700 rounded">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmitComment}>
|
||||
<div className="mb-4">
|
||||
<Label htmlFor="comment">Comment *</Label>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
placeholder="Write your comment (markdown supported)"
|
||||
value={newComment.comment}
|
||||
onChange={(value) => setNewComment(prev => ({ ...prev, comment: value || '' }))}
|
||||
height={300}
|
||||
preview={editorMode}
|
||||
commands={allCommands}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode(editorMode === 'edit' ? 'preview' : 'edit')}
|
||||
className={`text-sm ${editorMode !== 'edit' ? 'bg-[#6d9e37] text-white' : 'text-[#6d9e37]'} px-2 py-1 rounded-md border border-[#6d9e37]`}
|
||||
>
|
||||
{editorMode === 'edit' ? 'Preview' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={isSubmitting || !isLoggedIn}>
|
||||
{isSubmitting ? 'Submitting...' : 'Post Comment'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Image Upload Dialog */}
|
||||
<Dialog open={imageDialogOpen} onOpenChange={setImageDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Image</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image-url">From URL</Label>
|
||||
<Input
|
||||
id="image-url"
|
||||
type="text"
|
||||
placeholder="Enter image URL"
|
||||
value={imageUrlInput}
|
||||
onChange={(e) => setImageUrlInput(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInsertImageUrl}
|
||||
disabled={!imageUrlInput}
|
||||
className="mt-2"
|
||||
>
|
||||
Insert Image
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image-upload">Upload Image</Label>
|
||||
<Input
|
||||
id="image-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileSelect}
|
||||
/>
|
||||
{imageUploadPreview && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={imageUploadPreview}
|
||||
alt="Preview"
|
||||
className="max-h-40 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImageUpload}
|
||||
disabled={!imageUploadFile || isSubmitting}
|
||||
className="mt-2"
|
||||
>
|
||||
{isSubmitting ? 'Uploading...' : 'Upload & Insert'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-full h-full absolute bg-black inset-0 opacity-70 backdrop-blur-2xl rounded-lg" />
|
||||
<div className="w-10 h-10 rounded-full border-2 border-dotted border-[#6d9e37] absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" role="status">
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
</>
|
||||
) : !isLoggedIn ? (
|
||||
<>
|
||||
<div className="w-full h-full absolute bg-black inset-0 opacity-70 backdrop-blur-2xl rounded-lg" />
|
||||
<p className="text-gray-100 bg-gray-700 p-2 rounded-md shadow-xl italic absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
|
||||
Join the conversation! Log in or sign up to post a comment
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
188
src/components/ContactForm.jsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "./ui/select";
|
||||
import { Button } from './ui/button';
|
||||
export function ContactForm() {
|
||||
const [formState, setFormState] = useState({ name: '', email: '', company: '', service: '', message: '' });
|
||||
const [formStatus, setFormStatus] = useState('idle');
|
||||
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormState(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSelectChange = (value) => {
|
||||
setFormState(prev => ({ ...prev, service: value }));
|
||||
// console.log(formState)
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setFormStatus('submitting');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: formState.name,
|
||||
email: formState.email,
|
||||
company: formState.company,
|
||||
service_intrest: formState.service,
|
||||
message: formState.message
|
||||
};
|
||||
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=contact-form`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
// console.log('Form Response', data);
|
||||
|
||||
if(data.success) {
|
||||
setFormStatus('success');
|
||||
// Reset form only after successful submission
|
||||
setFormState({ name: '', email: '', company: '', service: '', message: '' });
|
||||
} else {
|
||||
setFormStatus('error');
|
||||
console.error('Backend error:', data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submission error:', error);
|
||||
setFormStatus('error');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6" id='contact-form'>
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* Name and Email */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="name" className="text-sm sm:text-base">Name*</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
value={formState.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
disabled={formStatus === 'submitting'}
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="email" className="text-sm sm:text-base">Email*</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="your.email@example.com"
|
||||
value={formState.email}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
disabled={formStatus === 'submitting'}
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company and Service Interest */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="company" className="text-sm sm:text-base">Company</Label>
|
||||
<Input
|
||||
id="company"
|
||||
name="company"
|
||||
placeholder="Your company name"
|
||||
value={formState.company}
|
||||
onChange={handleInputChange}
|
||||
disabled={formStatus === 'submitting'}
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
{/* dop_v1_b6a075ece5786faf7c58d21761dbf95d47af372da062d68a870ce2a0bae51adf */}
|
||||
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="service" className="text-sm sm:text-base">Service Interest*</Label>
|
||||
<Select
|
||||
value={formState.service}
|
||||
onValueChange={handleSelectChange}
|
||||
disabled={formStatus === 'submitting'}
|
||||
>
|
||||
<SelectTrigger className="text-sm sm:text-base">
|
||||
<SelectValue placeholder="Select a service" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="php">PHP Hosting</SelectItem>
|
||||
<SelectItem value="nodejs">Node.js Hosting</SelectItem>
|
||||
<SelectItem value="python">Python Hosting</SelectItem>
|
||||
<SelectItem value="kubernetes">Kubernetes (K8s)</SelectItem>
|
||||
<SelectItem value="k3s">K3s Lightweight Kubernetes</SelectItem>
|
||||
<SelectItem value="custom">Custom Solution</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="message" className="text-sm sm:text-base">Message*</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
placeholder="Tell us about your project requirements..."
|
||||
value={formState.message}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
disabled={formStatus === 'submitting'}
|
||||
className="min-h-[100px] sm:min-h-[150px] text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full mt-2 text-sm sm:text-base py-2 sm:py-3"
|
||||
disabled={formStatus === 'submitting'}
|
||||
>
|
||||
{formStatus === 'submitting' ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Sending...
|
||||
</>
|
||||
) : 'Send Message'}
|
||||
</Button>
|
||||
|
||||
{/* Status Messages */}
|
||||
{formStatus === 'success' && (
|
||||
<div
|
||||
className="p-3 sm:p-4 bg-green-900/30 border border-green-800 rounded-md text-green-400 text-center text-sm sm:text-base"
|
||||
aria-live="polite"
|
||||
>
|
||||
Thank you for your message! We'll get back to you soon.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formStatus === 'error' && (
|
||||
<div
|
||||
className="p-3 sm:p-4 bg-red-900/30 border border-red-800 rounded-md text-red-400 text-center text-sm sm:text-base"
|
||||
aria-live="assertive"
|
||||
>
|
||||
There was an error sending your message. Please try again.
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { Label } from './ui/label';
|
||||
import { Select } from './ui/select';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function ContactForm() {
|
||||
const [formState, setFormState] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
service: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
const [formStatus, setFormStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormState(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormStatus('submitting');
|
||||
|
||||
// Simulate form submission with a timeout
|
||||
setTimeout(() => {
|
||||
// In a real app, you would send the data to a server here
|
||||
console.log('Form submitted:', formState);
|
||||
setFormStatus('success');
|
||||
|
||||
// Reset form after successful submission
|
||||
setFormState({
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
service: '',
|
||||
message: '',
|
||||
});
|
||||
|
||||
// Reset status after showing success message for a while
|
||||
setTimeout(() => {
|
||||
setFormStatus('idle');
|
||||
}, 3000);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* Name and Email - Stack on mobile, side-by-side on tablet+ */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="name" className="text-sm sm:text-base">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="Your name"
|
||||
value={formState.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="email" className="text-sm sm:text-base">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="your.email@example.com"
|
||||
value={formState.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Company and Service Interest - Stack on mobile, side-by-side on tablet+ */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="company" className="text-sm sm:text-base">Company</Label>
|
||||
<Input
|
||||
id="company"
|
||||
name="company"
|
||||
placeholder="Your company name"
|
||||
value={formState.company}
|
||||
onChange={handleChange}
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="service" className="text-sm sm:text-base">Service Interest</Label>
|
||||
<Select
|
||||
id="service"
|
||||
name="service"
|
||||
value={formState.service}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="text-sm sm:text-base"
|
||||
>
|
||||
<option value="" disabled>Select a service</option>
|
||||
<option value="php">PHP Hosting</option>
|
||||
<option value="nodejs">Node.js Hosting</option>
|
||||
<option value="python">Python Hosting</option>
|
||||
<option value="kubernetes">Kubernetes (K8s)</option>
|
||||
<option value="k3s">K3s Lightweight Kubernetes</option>
|
||||
<option value="custom">Custom Solution</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<Label htmlFor="message" className="text-sm sm:text-base">Message</Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
placeholder="Tell us about your project requirements..."
|
||||
value={formState.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="min-h-[100px] sm:min-h-[150px] text-sm sm:text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full mt-2 text-sm sm:text-base py-2 sm:py-3"
|
||||
disabled={formStatus === 'submitting'}
|
||||
>
|
||||
{formStatus === 'submitting' ? (
|
||||
<>
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Sending...
|
||||
</>
|
||||
) : 'Send Message'}
|
||||
</Button>
|
||||
|
||||
{formStatus === 'success' && (
|
||||
<div className="p-3 sm:p-4 bg-green-900/30 border border-green-800 rounded-md text-green-400 text-center text-sm sm:text-base">
|
||||
Thank you for your message! We'll get back to you soon.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formStatus === 'error' && (
|
||||
<div className="p-3 sm:p-4 bg-red-900/30 border border-red-800 rounded-md text-red-400 text-center text-sm sm:text-base">
|
||||
There was an error sending your message. Please try again.
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
962
src/components/DomainSetupForm_old.jsx
Normal file
@@ -0,0 +1,962 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Toast } from './Toast';
|
||||
import { TemplatePreview } from './TemplatePreview';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from "./ui/input";
|
||||
import { Label, Select } from '@radix-ui/react-select';
|
||||
export const DomainSetupForm = ({ defaultSubdomain }) => {
|
||||
// Deployment type and app selections
|
||||
const [deploymentType, setDeploymentType] = useState('app');
|
||||
const [appType, setAppType] = useState('wordpress');
|
||||
const [sampleWebAppType, setSampleWebAppType] = useState('developer'); // New state for sample web app type
|
||||
const [sourceType, setSourceType] = useState('public');
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
const [deploymentKey, setDeploymentKey] = useState('');
|
||||
const [fileName, setFileName] = useState('');
|
||||
|
||||
// Domain configuration
|
||||
const [useSubdomain, setUseSubdomain] = useState(true);
|
||||
const [useCustomDomain, setUseCustomDomain] = useState(false);
|
||||
const [customDomain, setCustomDomain] = useState('');
|
||||
const [customSubdomain, setCustomSubdomain] = useState('');
|
||||
const [domainType, setDomainType] = useState('domain'); // 'domain' or 'subdomain'
|
||||
|
||||
// Domain validation states
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isValidDomain, setIsValidDomain] = useState(false);
|
||||
const [validationMessage, setValidationMessage] = useState('');
|
||||
|
||||
// DNS configuration
|
||||
const [dnsMethod, setDnsMethod] = useState('cname');
|
||||
const [showDnsConfig, setShowDnsConfig] = useState(false);
|
||||
const [dnsVerified, setDnsVerified] = useState({ cname: false, ns: false, a: false, ip: false });
|
||||
const [txnId, setTxnId] = useState('');
|
||||
const [userEmail, setUserEmail] = useState('');
|
||||
|
||||
const [panelType, setPanelType] = useState('');
|
||||
const [panelServicesId, setPanelServicesId] = useState('');
|
||||
|
||||
|
||||
const [vpnContinent, setVpnContinent] = useState('');
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
const PUBLIC_SERVICE_API_URL = import.meta.env.PUBLIC_SERVICE_API_URL;
|
||||
|
||||
const [selectedcycle, setSelectedcycle] = useState('');
|
||||
const [selectedPrice, setSelectedPrice] = useState(0);
|
||||
|
||||
const handleCheckboxChange = (cycle, price) => {
|
||||
if (selectedcycle === cycle) {
|
||||
setSelectedcycle('');
|
||||
setSelectedPrice(0);
|
||||
} else {
|
||||
setSelectedcycle(cycle);
|
||||
setSelectedPrice(price);
|
||||
}
|
||||
// console.log(selectedcycle, ' ', selectedPrice);
|
||||
};
|
||||
const handlePanelBuyNow = (whichService) => {
|
||||
// console.log(whichService);
|
||||
// Disable button during processing
|
||||
const buyButton = document.getElementById('buy-button'); // Add ID to your button
|
||||
if (buyButton) buyButton.disabled = true;
|
||||
showToast('Loading...');
|
||||
|
||||
if(whichService === 'php-mysql-with-admin-panel'){
|
||||
// console.log('php-mysql-with-admin-panel: ', whichService);
|
||||
const formData = new FormData();
|
||||
formData.append('service', panelType);
|
||||
formData.append('serviceId', panelServicesId);
|
||||
formData.append('cycle', selectedcycle);
|
||||
formData.append('amount', selectedPrice); //selectedPrice
|
||||
|
||||
fetch(`${PUBLIC_USER_API_URL}?query=initiate_payment`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if(data.success === true){
|
||||
setTxnId(data.txn_id);
|
||||
setUserEmail(data.user_email);
|
||||
window.location.href = `/make-payment?query=get-initiated_payment&orderId=${data.order_id}`
|
||||
// redirectToPayU(data);
|
||||
showToast('Redirecting to payment page...');
|
||||
} else {
|
||||
throw new Error(data.message || 'Payment initialization failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast(error.message || 'Payment failed. Please try again.');
|
||||
console.error('An error occurred:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (buyButton) buyButton.disabled = false;
|
||||
});
|
||||
} else if(whichService === 'vpn'){
|
||||
const formData = new FormData();
|
||||
formData.append('service', deploymentType);
|
||||
formData.append('serviceId', 'vpnservices');
|
||||
formData.append('cycle', selectedcycle);
|
||||
formData.append('amount', selectedPrice);
|
||||
fetch(`${PUBLIC_USER_API_URL}?query=initiate_payment`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if(data.success === true){
|
||||
setTxnId(data.txn_id);
|
||||
setUserEmail(data.user_email);
|
||||
// window.location.href = `/make-payment?query=get-initiated_payment&orderId=${data.order_id}`
|
||||
window.location.href = `/success?service=vpn&orderId=${data.order_id}`
|
||||
// redirectToPayU(data);
|
||||
showToast('Redirecting to payment page...');
|
||||
} else {
|
||||
throw new Error(data.message || 'Payment initialization failed');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showToast(error.message || 'Payment failed. Please try again.');
|
||||
console.error('An error occurred:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (buyButton) buyButton.disabled = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Form validation
|
||||
const [formValid, setFormValid] = useState(true);
|
||||
|
||||
// Toast notification
|
||||
const [toast, setToast] = useState({ visible: false, message: '' });
|
||||
|
||||
// File upload reference
|
||||
const fileInputRef = React.useRef(null);
|
||||
|
||||
// Effect for handling domain type changes
|
||||
useEffect(() => {
|
||||
if (!useCustomDomain) {
|
||||
setShowDnsConfig(false);
|
||||
setIsValidDomain(false);
|
||||
setValidationMessage('');
|
||||
setIsValidating(false);
|
||||
}
|
||||
|
||||
validateForm();
|
||||
}, [useCustomDomain, dnsVerified.cname, dnsVerified.ns, dnsVerified.ns, domainType, dnsMethod]);
|
||||
|
||||
// Show toast notification
|
||||
const showToast = (message) => {
|
||||
setToast({ visible: true, message });
|
||||
setTimeout(() => setToast({ visible: false, message: '' }), 3000);
|
||||
};
|
||||
|
||||
// Handle deployment type change
|
||||
const handleDeploymentTypeChange = (e) => {
|
||||
setDeploymentType(e.target.value);
|
||||
};
|
||||
|
||||
// Handle source type change
|
||||
const handleSourceTypeChange = (e) => {
|
||||
setSourceType(e.target.value);
|
||||
};
|
||||
|
||||
// Handle file upload
|
||||
const handleFileChange = (e) => {
|
||||
if (e.target.files.length > 0) {
|
||||
setFileName(e.target.files[0].name);
|
||||
} else {
|
||||
setFileName('');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle domain checkbox changes
|
||||
const handleUseSubdomainChange = (e) => {
|
||||
setUseSubdomain(e.target.checked);
|
||||
|
||||
// If CNAME record is selected, SiliconPin subdomain must be enabled
|
||||
if (useCustomDomain && dnsMethod === 'cname' && !e.target.checked) {
|
||||
setUseCustomDomain(false);
|
||||
}
|
||||
|
||||
validateForm();
|
||||
};
|
||||
|
||||
const handleUseCustomDomainChange = (e) => {
|
||||
setUseCustomDomain(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
setShowDnsConfig(false);
|
||||
setIsValidDomain(false);
|
||||
setValidationMessage('');
|
||||
setIsValidating(false);
|
||||
setDnsVerified({
|
||||
cname: false,
|
||||
ns: false,
|
||||
a: false,
|
||||
ip: false
|
||||
});
|
||||
} else {
|
||||
// Force SiliconPin subdomain to be checked if custom domain is checked
|
||||
setUseSubdomain(true);
|
||||
}
|
||||
|
||||
validateForm();
|
||||
};
|
||||
|
||||
// Handle domain type change
|
||||
const handleDomainTypeChange = (e) => {
|
||||
setDomainType(e.target.value);
|
||||
|
||||
// Reset validation when changing domain type
|
||||
setIsValidDomain(false);
|
||||
setValidationMessage('');
|
||||
setDnsVerified({
|
||||
cname: false,
|
||||
ns: false,
|
||||
a: false
|
||||
});
|
||||
|
||||
validateForm();
|
||||
};
|
||||
|
||||
// Handle domain and subdomain input changes
|
||||
const handleDomainChange = (e) => {
|
||||
const cleanedValue = e.target.value.replace(/^(https?:\/\/)?(www\.)?/i, '').replace(/\/+$/, '').trim();
|
||||
setCustomDomain(cleanedValue);
|
||||
};
|
||||
|
||||
const handleSubdomainChange = (e) => {
|
||||
const cleanedValue = e.target.value.replace(/^(https?:\/\/)?/i, '').replace(/\/+$/, '').trim();
|
||||
setCustomSubdomain(cleanedValue);
|
||||
};
|
||||
|
||||
// Handle DNS method change
|
||||
const handleDnsMethodChange = (e) => {
|
||||
setDnsMethod(e.target.value);
|
||||
|
||||
// If changing to CNAME, ensure SiliconPin subdomain is enabled
|
||||
if (e.target.value === 'cname') {
|
||||
setUseSubdomain(true);
|
||||
}
|
||||
|
||||
// Reset DNS verification
|
||||
setDnsVerified(prev => ({
|
||||
...prev,
|
||||
[e.target.value]: false
|
||||
}));
|
||||
|
||||
validateForm();
|
||||
};
|
||||
|
||||
// Validate domain
|
||||
const validateDomain = async () => {
|
||||
const domain = domainType === 'domain' ? customDomain : customSubdomain;
|
||||
|
||||
// Reset validation state
|
||||
setIsValidating(true);
|
||||
setIsValidDomain(false);
|
||||
setValidationMessage('');
|
||||
setShowDnsConfig(false);
|
||||
|
||||
// Check if domain is empty
|
||||
if (!domain) {
|
||||
setValidationMessage('Please enter a domain name.');
|
||||
setIsValidating(false);
|
||||
setIsValidDomain(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
const domainFormatRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i;
|
||||
if (!domainFormatRegex.test(domain)) {
|
||||
setValidationMessage('Domain format is invalid. Please check your entry.');
|
||||
setIsValidating(false);
|
||||
setIsValidDomain(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Make API call to validate domain
|
||||
const response = await fetch(`${PUBLIC_SERVICE_API_URL}?query=validate-domain`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ domain })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.valid) {
|
||||
setValidationMessage('Domain is valid and registered.');
|
||||
setIsValidDomain(true);
|
||||
setShowDnsConfig(true);
|
||||
} else {
|
||||
setValidationMessage(data.message || 'Domain appears to be unregistered or unavailable.');
|
||||
setIsValidDomain(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Domain validation error:', error);
|
||||
setValidationMessage('Error validating domain. Please try again.');
|
||||
setIsValidDomain(false);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
validateForm();
|
||||
}
|
||||
};
|
||||
|
||||
// Check DNS configuration
|
||||
const checkSubDomainCname = () => {
|
||||
const domainToCheck = customDomain || customSubdomain;
|
||||
|
||||
fetch(`${PUBLIC_SERVICE_API_URL}?query=check-c-name`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `domain=${encodeURIComponent(domainToCheck)}`
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
checkDnsConfig('cname');
|
||||
showToast(`Checking ${type}... (This would verify DNS in a real app)`);
|
||||
// console.log('CNAME record:', data.cname);
|
||||
// Handle success - update UI
|
||||
} else {
|
||||
console.error('Error:', data.message);
|
||||
// Handle error
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fetch error:', error);
|
||||
// Show error to user
|
||||
});
|
||||
};
|
||||
const checkDnsConfig = (type) => {
|
||||
showToast(`Checking ${type}... (This would verify DNS in a real app)`);
|
||||
|
||||
// Simulate DNS check
|
||||
setTimeout(() => {
|
||||
setDnsVerified(prev => ({
|
||||
...prev,
|
||||
[type]: true
|
||||
}));
|
||||
|
||||
showToast(`${type} verified successfully!`);
|
||||
validateForm();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Copy to clipboard
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => {
|
||||
showToast('Copied to clipboard!');
|
||||
})
|
||||
.catch(err => {
|
||||
showToast('Failed to copy: ' + err);
|
||||
});
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const validateForm = () => {
|
||||
// For custom domain, require DNS verification
|
||||
if (useCustomDomain) {
|
||||
if (dnsMethod === 'cname' && !dnsVerified.cname) {
|
||||
setFormValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dnsMethod === 'ns' && !dnsVerified.ns) {
|
||||
setFormValid(false);
|
||||
return;
|
||||
}
|
||||
if (dnsMethod === 'ip' && !dnsVerified.ip) {
|
||||
setFormValid(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setFormValid(true);
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formValid) {
|
||||
showToast('Please complete DNS verification before deploying.');
|
||||
return;
|
||||
}
|
||||
|
||||
// In a real app, this would submit the form data to the server
|
||||
// console.log([{ deploymentType, appType, sampleWebAppType, sourceType, repoUrl, deploymentKey, useSubdomain, useCustomDomain, customDomain, customSubdomain, domainType, dnsMethod}]);
|
||||
|
||||
showToast('Form submitted successfully!');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-800 rounded-lg p-6 sm:p-8 border border-neutral-700">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Deployment Type Selection */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="deployment-type" className="block text-white font-medium">Deployment Type</label>
|
||||
<select
|
||||
id="deployment-type"
|
||||
value={deploymentType}
|
||||
onChange={handleDeploymentTypeChange}
|
||||
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
>
|
||||
<option value="app">💻 Deploy an App</option>
|
||||
<option value="source">⚙️ From Source</option>
|
||||
<option value="static">📄 Static Site Upload</option>
|
||||
<option value="sample-web-app">🌐 Sample Web App</option>
|
||||
<option value="php-mysql-with-admin-panel">🗄️ PHP MYSQL with a admin panel</option>
|
||||
<option value="vpn">🔒 VPN</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* App Options */}
|
||||
{deploymentType === 'app' && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="app-type" className="block text-white font-medium">Select Application</label>
|
||||
<select
|
||||
id="app-type"
|
||||
value={appType}
|
||||
onChange={(e) => setAppType(e.target.value)}
|
||||
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
>
|
||||
<option value="wordpress">🔌 WordPress</option>
|
||||
<option value="prestashop">🛒 PrestaShop</option>
|
||||
<option value="laravel">🚀 Laravel</option>
|
||||
<option value="cakephp">🍰 CakePHP</option>
|
||||
<option value="symfony">🎯 Symfony</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sample Web App Options */}
|
||||
{deploymentType === 'sample-web-app' && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="sample-web-app-type" className="block text-white font-medium">Select Template Type</label>
|
||||
<select
|
||||
id="sample-web-app-type"
|
||||
value={sampleWebAppType}
|
||||
onChange={(e) => setSampleWebAppType(e.target.value)}
|
||||
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
>
|
||||
<option value="developer">👨💻 Developer Portfolio</option>
|
||||
<option value="designer">🎨 Designer Portfolio</option>
|
||||
<option value="photographer">📸 Photographer Portfolio</option>
|
||||
<option value="documentation">📚 Documentation Site</option>
|
||||
<option value="business">🏢 Single Page Business Site</option>
|
||||
</select>
|
||||
|
||||
<div className="mt-4 p-4 bg-neutral-700/30 rounded-md border border-neutral-600">
|
||||
<div className="flex items-start">
|
||||
<div className="mr-3 text-2xl">ℹ️</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-neutral-300">
|
||||
Deploy a ready-to-use template that you can customize. We'll set up the basic structure and you can modify it to fit your needs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Preview */}
|
||||
<TemplatePreview templateType={sampleWebAppType} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Options */}
|
||||
{deploymentType === 'source' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="source-type" className="block text-white font-medium">Source Type</label>
|
||||
<select
|
||||
id="source-type"
|
||||
value={sourceType}
|
||||
onChange={handleSourceTypeChange}
|
||||
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
>
|
||||
<option value="public">🌐 Public Repository</option>
|
||||
<option value="private">🔒 Private Repository</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<label htmlFor="repo-url" className="block text-white font-medium mb-2">Repository URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="repo-url"
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/username/repository"
|
||||
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{sourceType === 'private' && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label htmlFor="deployment-key" className="block text-white font-medium">Deployment Key</label>
|
||||
<button type="button" onClick={() => showToast('Deployment keys are used for secure access to private repositories')} className="text-neutral-400 hover:text-white focus:outline-none" aria-label="Deployment Key Information">
|
||||
<span className="text-lg">❓</span>
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="deployment-key" value={deploymentKey} onChange={(e) => setDeploymentKey(e.target.value)} placeholder="Paste your SSH private key here" rows="6" className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37] font-mono text-sm" />
|
||||
<p className="mt-1 text-sm text-neutral-400">Your private key is used only for deploying and is never stored on our servers.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Static Site Options */}
|
||||
{deploymentType === 'static' && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-neutral-700/50 rounded-md text-neutral-300 text-center">
|
||||
Upload the zip file containing your static website
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<label htmlFor="file-upload" className="block text-white font-medium mb-2">Upload File (ZIP/TAR)</label>
|
||||
<div className="flex items-center justify-center w-full">
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer border-neutral-600 hover:border-[#6d9e37]"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<span className="text-3xl mb-3 text-neutral-400">📁</span>
|
||||
<p className="mb-2 text-sm text-neutral-400">
|
||||
<span className="font-semibold">Click to upload</span> or drag and drop
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">ZIP or TAR files only (max. 100MB)</p>
|
||||
</div>
|
||||
<input
|
||||
id="file-upload"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
accept=".zip,.tar"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{fileName && (
|
||||
<div className="mt-2 text-sm text-neutral-400">
|
||||
Selected file: {fileName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
deploymentType === 'php-mysql-with-admin-panel' && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="app-type" className="block text-white font-medium">Select Panel</label>
|
||||
<select
|
||||
id="app-type"
|
||||
value={panelType}
|
||||
onChange={(e) => {
|
||||
setPanelType(e.target.value);
|
||||
setPanelServicesId(e.target.options[e.target.selectedIndex].dataset.id);
|
||||
}}
|
||||
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
>
|
||||
<option value="">-Select-</option>
|
||||
<option value="Hestia-Panel" data-id="4ksYhjFy">🧰 Hestia Panel</option>
|
||||
<option value="Webmin" data-id="5ksYAhFz">🖥️ Webmin</option>
|
||||
<option value="cPanel" data-id="6jgThjZx">📊 cPanel</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
{
|
||||
deploymentType === 'vpn' && (
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="app-type" className="block text-white font-medium">Select Contient</label>
|
||||
<select
|
||||
id="app-type"
|
||||
value={vpnContinent}
|
||||
onChange={(e) => {
|
||||
setVpnContinent(e.target.value);
|
||||
// setVpnContinent(e.target.options[e.target.selectedIndex].dataset.id);
|
||||
}}
|
||||
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
>
|
||||
<option value="">-Select-</option>
|
||||
<option value="India" data-id="7ksXhkFy">India</option>
|
||||
<option value="America" data-id="8ksAshGz">America</option>
|
||||
<option value="Europe" data-id="9LgFhjVx">Europe</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
deploymentType === 'vpn' && (
|
||||
<>
|
||||
<ul className="flex justify-between text-sm">
|
||||
{["Unlimited bandwidth", "WireGuard® protocol", "Global server locations", "No activity logs"].map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[#6d9e37] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" ><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
<span className='text-zinc-400'>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className='flex flex-row justify-between items-center gap-x-6'>
|
||||
<label className={`border ${selectedcycle === 'monthly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer`}>
|
||||
<input type="checkbox" checked={selectedcycle === 'monthly'} onChange={() => handleCheckboxChange('monthly', 100)} className="hidden" />
|
||||
<p className='text-3xl font-bold text-center'>₹100</p>
|
||||
<span>Monthly</span>
|
||||
</label>
|
||||
|
||||
<label className={`border ${selectedcycle === 'yearly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer`}>
|
||||
<input type="checkbox" checked={selectedcycle === 'yearly'} onChange={() => handleCheckboxChange('yearly', 1000)} className="hidden" />
|
||||
<p className='text-3xl font-bold text-center'>₹1000</p>
|
||||
<span>Yearly</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className='text-white'>
|
||||
{selectedcycle && (
|
||||
<p className={`${selectedcycle === 'monthly' ? 'text-left' : 'text-end'}`}>You selected <strong>{selectedcycle}</strong> plan at ₹{selectedPrice}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
deploymentType === 'php-mysql-with-admin-panel' && panelType === 'Hestia-Panel' && (
|
||||
<>
|
||||
<ul className="flex justify-between text-sm">
|
||||
{["5 Domains", "free Let's Encrypt SSL", "1 MariaDB database", "phpMyAdmin"].map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[#6d9e37] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" ><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
<span className='text-zinc-400'>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className='flex flex-row justify-between items-center gap-x-6'>
|
||||
<label className={`border ${selectedcycle === 'monthly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer`}>
|
||||
<input type="checkbox" checked={selectedcycle === 'monthly'} onChange={() => handleCheckboxChange('monthly', 200)} className="hidden" />
|
||||
<p className='text-3xl font-bold text-center'>₹200</p>
|
||||
<span>Monthly</span>
|
||||
</label>
|
||||
|
||||
<label className={`border ${selectedcycle === 'yearly' ? 'bg-[#6d9e37]' : ''} border-[#6d9e37] text-white px-4 py-2 text-center rounded-md w-full cursor-pointer`}>
|
||||
<input type="checkbox" checked={selectedcycle === 'yearly'} onChange={() => handleCheckboxChange('yearly', 2000)} className="hidden" />
|
||||
<p className='text-3xl font-bold text-center'>₹2000</p>
|
||||
<span>Yearly</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className='text-white'>
|
||||
{selectedcycle && (
|
||||
<p className={`${selectedcycle === 'monthly' ? 'text-left' : 'text-end'}`}>You selected <strong>{selectedcycle}</strong> plan at ₹{selectedPrice}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
deploymentType === 'php-mysql-with-admin-panel' && panelType === 'cPanel' ? (
|
||||
<div>
|
||||
<p>
|
||||
cPanel is a proprietary software. Here at Siliconpin, we encourage using freedom-oriented software.
|
||||
If you need a cPanel, you can visit
|
||||
<a className='text-[#6d9e37]' href="https://cicdhosting.com" target='_blank'>https://cicdhosting.com</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
) : deploymentType === 'php-mysql-with-admin-panel' && (
|
||||
<Button onClick={() => {handlePanelBuyNow('php-mysql-with-admin-panel')}} className='w-full'>Proceed to Pay</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
{
|
||||
deploymentType === 'vpn' && (
|
||||
<Button onClick={() => {handlePanelBuyNow('vpn')}} className='w-full'>Proceed to Pay</Button>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
deploymentType !== 'php-mysql-with-admin-panel' && deploymentType !== 'vpn' &&(
|
||||
<>
|
||||
{/* Domain Configuration */}
|
||||
<div className="pt-4 border-t border-neutral-700">
|
||||
<h3 className="text-lg font-medium text-white mb-4">Destination</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* SiliconPin Subdomain */}
|
||||
<div className="flex items-start gap-2">
|
||||
<input type="checkbox" id="use-subdomain" checked={useSubdomain} onChange={handleUseSubdomainChange} className="mt-1 accent-[#6d9e37]" />
|
||||
<div className="flex-1">
|
||||
<label htmlFor="use-subdomain" className="block text-white font-medium">Use SiliconPin Subdomain</label>
|
||||
<div className="mt-2 flex">
|
||||
<input
|
||||
type="text"
|
||||
id="subdomain"
|
||||
name="destination-domain"
|
||||
value={defaultSubdomain}
|
||||
className="rounded-l-md py-2 px-3 bg-neutral-600 border-y border-l border-neutral-600 text-neutral-300 focus:outline-none w-1/3 font-mono"
|
||||
readOnly
|
||||
/>
|
||||
<span className="rounded-r-md py-2 px-3 bg-neutral-800 border border-neutral-700 text-neutral-400 w-2/3">.subdomain.siliconpin.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Domain */}
|
||||
<div className="flex items-start gap-2">
|
||||
<input type="checkbox" id="use-custom-domain" name="destination-domain" checked={useCustomDomain} onChange={handleUseCustomDomainChange} className="mt-1 accent-[#6d9e37]" />
|
||||
<div className="flex-1">
|
||||
<label htmlFor="use-custom-domain" className="block text-white font-medium">Use Custom Domain</label>
|
||||
|
||||
{useCustomDomain && (
|
||||
<div className="mt-3 space-y-4">
|
||||
{/* Domain Type Selection */}
|
||||
<div className="flex flex-col space-y-3 sm:flex-row sm:space-y-0 sm:space-x-4">
|
||||
<div className="flex items-center">
|
||||
<input type="radio" id="domain-type-domain" name="domain-type" value="domain" checked={domainType === 'domain'} onChange={handleDomainTypeChange} className="mr-2 accent-[#6d9e37]" />
|
||||
<label htmlFor="domain-type-domain" className="text-white">Root Domain</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input type="radio" id="domain-type-subdomain" name="domain-type" value="subdomain" checked={domainType === 'subdomain'} onChange={handleDomainTypeChange} className="mr-2 accent-[#6d9e37]"/>
|
||||
<label htmlFor="domain-type-subdomain" className="text-white">Subdomain</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Domain Input */}
|
||||
{domainType === 'domain' ? (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="custom-domain"
|
||||
value={customDomain}
|
||||
onChange={handleDomainChange}
|
||||
placeholder="yourdomain.com"
|
||||
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
Enter domain without http://, www, or trailing slashes (example.com). You can configure www or other subdomains later.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="custom-subdomain"
|
||||
value={customSubdomain}
|
||||
onChange={handleSubdomainChange}
|
||||
placeholder="blog.yourdomain.com"
|
||||
className="w-full rounded-md py-2 px-3 bg-neutral-700 border border-neutral-600 text-white focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-400">
|
||||
Enter the full subdomain without http:// or trailing slashes. www and protocol prefixes will be automatically removed.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain Validation */}
|
||||
<button
|
||||
disabled={!customDomain && !customSubdomain}
|
||||
type="button"
|
||||
onClick={validateDomain}
|
||||
className={`px-4 py-2 ${ !customDomain && !customSubdomain ? 'bg-neutral-600 cursor-not-allowed' : 'bg-[#6d9e37] focus:ring-[#6d9e37] transition-colors'} text-white font-medium rounded-md transition-colors focus:outline-none`}
|
||||
>
|
||||
Validate Domain
|
||||
</button>
|
||||
|
||||
{/* Validation Status */}
|
||||
{useCustomDomain && isValidating && (
|
||||
<div className="p-3 bg-neutral-700/50 rounded-md">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
|
||||
<p className="text-white">Verifying domain registration and availability...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{useCustomDomain && !isValidating && validationMessage && (
|
||||
<div className={`p-3 rounded-md ${isValidDomain ? 'bg-green-900/20 border border-green-700/50' : 'bg-red-900/20 border border-red-700/50'}`}>
|
||||
<p className={isValidDomain ? 'text-green-400' : 'text-red-400'}>
|
||||
{validationMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* DNS Configuration Options */}
|
||||
{showDnsConfig && (
|
||||
<div className="mt-4 space-y-4 p-4 rounded-md bg-neutral-800/50 border border-neutral-700">
|
||||
<h4 className="text-white font-medium">Connect Your Domain</h4>
|
||||
|
||||
{/* CNAME Record Option */}
|
||||
<div className={`p-4 bg-neutral-700/30 rounded-md space-y-3 ${dnsMethod === 'cname' ? 'border-2 border-[#6d9e37]' : 'border border-neutral-600'}`}>
|
||||
<label for="dns-cname" className={`flex items-start cursor-pointer`}>
|
||||
<input type="radio" id="dns-cname" name="dns-method" value="cname" checked={dnsMethod === 'cname'} onChange={handleDnsMethodChange} className="mt-1 mr-2 accent-[#6d9e37]" />
|
||||
<div className="flex-1">
|
||||
<label htmlFor="dns-cname" className="block text-white font-medium">Use CNAME Record</label>
|
||||
<p className="text-sm text-neutral-300">Point your domain to our SiliconPin subdomain</p>
|
||||
|
||||
<div className="mt-3 flex items-center">
|
||||
<div className="bg-neutral-800 p-2 rounded font-mono text-sm text-neutral-300">
|
||||
{defaultSubdomain}.subdomain.siliconpin.com
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToClipboard(`${defaultSubdomain}.subdomain.siliconpin.com`)}
|
||||
className="ml-2 text-[#6d9e37] hover:text-white"
|
||||
aria-label="Copy CNAME value"
|
||||
>
|
||||
<span className="text-lg">📋</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (checkSubDomainCname())}
|
||||
className={`px-3 py-1 text-white text-sm rounded
|
||||
${dnsVerified.cname
|
||||
? 'bg-green-700 hover:bg-green-600'
|
||||
: 'bg-neutral-600 hover:bg-neutral-500'}`}
|
||||
>
|
||||
{dnsVerified.cname ? '✓ CNAME Verified' : 'Check CNAME'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Nameserver Option (only for full domains, not subdomains) */}
|
||||
{domainType === 'domain' && (
|
||||
<>
|
||||
<div className={`p-4 bg-neutral-700/30 rounded-md space-y-3 ${dnsMethod === 'ns' ? 'border-2 border-[#6d9e37]' : 'border border-neutral-600'}`}>
|
||||
<label for="dns-ns" className="flex items-start cursor-pointer">
|
||||
<input type="radio" id="dns-ns" name="dns-method" value="ns" checked={dnsMethod === 'ns'} onChange={handleDnsMethodChange} className="mt-1 mr-2 accent-[#6d9e37]" />
|
||||
<div className="flex-1">
|
||||
<label htmlFor="dns-ns" className="block text-white font-medium">Use Our Nameservers</label>
|
||||
<p className="text-sm text-neutral-300">Update your domain's nameservers to use ours</p>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-neutral-800 p-2 rounded font-mono text-sm text-neutral-300">ns1.siliconpin.com</div>
|
||||
<button type="button" onClick={() => copyToClipboard('ns1.siliconpin.com')} className="ml-2 text-[#6d9e37] hover:text-white" aria-label="Copy nameserver value">
|
||||
<span className="text-lg">📋</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="bg-neutral-800 p-2 rounded font-mono text-sm text-neutral-300">ns2.siliconpin.com</div>
|
||||
<button type="button" onClick={() => copyToClipboard('ns2.siliconpin.com')} className="ml-2 text-[#6d9e37] hover:text-white" aria-label="Copy nameserver value">
|
||||
<span className="text-lg">📋</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkDnsConfig('ns')}
|
||||
className={`px-3 py-1 text-white text-sm rounded
|
||||
${dnsVerified.ns
|
||||
? 'bg-green-700 hover:bg-green-600'
|
||||
: 'bg-neutral-600 hover:bg-neutral-500'}`}
|
||||
>
|
||||
{dnsVerified.ns ? '✓ Nameservers Verified' : 'Check Nameservers'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 bg-neutral-700/30 rounded-md space-y-3 ${dnsMethod === 'ip' ? 'border-2 border-[#6d9e37]' : 'border border-neutral-600'}`}>
|
||||
<label for="dns-ip" className="flex items-start cursor-pointer">
|
||||
<input type="radio" id="dns-ip" name="dns-method" value="ip" checked={dnsMethod === 'ip'} onChange={handleDnsMethodChange} className="mt-1 mr-2 accent-[#6d9e37]" />
|
||||
<div className="flex-1">
|
||||
<label htmlFor="dns-ip" className="block text-white font-medium">Use Our IP Address</label>
|
||||
<p className="text-sm text-neutral-300">Update your domain's nameservers to use ours</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="flex items-center">
|
||||
<div className="bg-neutral-800 p-2 rounded font-mono text-sm text-neutral-300">xxx.xxx.x.xx</div>
|
||||
<button type="button" onClick={() => copyToClipboard('xxx.xxx.x.xx')} className="ml-2 text-[#6d9e37] hover:text-white" aria-label="Copy nameserver value">
|
||||
<span className="text-lg">📋</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => checkDnsConfig('ip')}
|
||||
className={`px-3 py-1 text-white text-sm rounded
|
||||
${dnsMethod === 'ip'
|
||||
? 'bg-green-700 hover:bg-green-600'
|
||||
: 'bg-neutral-600 hover:bg-neutral-500'}`}
|
||||
>
|
||||
{/* {dnsVerified.ip ? '✓ IP Address Verified' : 'Check IP Address'} */}
|
||||
Proceed to Pay
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Form Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={useCustomDomain && !formValid}
|
||||
className={`w-full mt-6 px-6 py-3 text-white font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-800
|
||||
${useCustomDomain && !formValid
|
||||
? 'bg-neutral-600 cursor-not-allowed'
|
||||
: 'bg-[#6d9e37] hover:bg-[#598035] focus:ring-[#6d9e37] transition-colors'
|
||||
}`}
|
||||
>
|
||||
Start the Deployment
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</form>
|
||||
<Toast visible={toast.visible} message={toast.message} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// upi://pay?pa=merchant@bank&pn=Merchant%20Inc&am=100.00&cu=INR&tn=Payment%20for%20goods
|
||||
|
||||
|
||||
@@ -186,12 +186,7 @@ export default function HireHumanDeveloper() {
|
||||
<h4 className="text-sm font-semibold mb-1 text-neutral-950">Specialized Skills:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedType.skills.map((skill, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-block bg-zinc-100 text-zinc-800 text-xs px-2 py-1 rounded"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
<span key={index} className="inline-block bg-zinc-100 text-zinc-800 text-xs px-2 py-1 rounded">{skill}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
56
src/components/ImageSlider/ImageSlider.css
Normal file
@@ -0,0 +1,56 @@
|
||||
.slider-container {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slider {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.slider img {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.left-arrow, .right-arrow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
font-size: 3rem;
|
||||
color: #fff;
|
||||
z-index: 10;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.left-arrow {
|
||||
left: 32px;
|
||||
}
|
||||
|
||||
.right-arrow {
|
||||
right: 32px;
|
||||
}
|
||||
|
||||
.dots-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
margin: 0 5px;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
color: #333;
|
||||
}
|
||||
174
src/components/ImageSlider/ImageSlider.jsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
function ImageSlider({ images }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [startX, setStartX] = useState(0);
|
||||
const [autoplay, setAutoplay] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
const trackRef = useRef(null);
|
||||
const autoplayRef = useRef(null);
|
||||
// Autoplay
|
||||
useEffect(() => {
|
||||
if (autoplay) {
|
||||
autoplayRef.current = setInterval(() => {
|
||||
setCurrentIndex(prev => (prev + 1) % images.length);
|
||||
}, 4000);
|
||||
}
|
||||
return () => clearInterval(autoplayRef.current);
|
||||
}, [autoplay, images.length]);
|
||||
|
||||
// ESC key for fullscreen exit
|
||||
useEffect(() => {
|
||||
const handleEsc = (e) => {
|
||||
if (e.key === 'Escape') setIsFullscreen(false);
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, []);
|
||||
|
||||
// Drag Start
|
||||
const handleDragStart = (e) => {
|
||||
setIsDragging(true);
|
||||
setAutoplay(false);
|
||||
setStartX(e.type.includes('mouse') ? e.clientX : e.touches[0].clientX);
|
||||
};
|
||||
|
||||
// Drag Move
|
||||
const handleDragMove = (e) => {
|
||||
if (!isDragging || !trackRef.current) return;
|
||||
const currentX = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX;
|
||||
const diff = currentX - startX;
|
||||
trackRef.current.style.transform = `translateX(calc(${-currentIndex * 100}% + ${diff}px))`;
|
||||
};
|
||||
|
||||
// Drag End
|
||||
const handleDragEnd = (e) => {
|
||||
if (!isDragging || !trackRef.current) return;
|
||||
setIsDragging(false);
|
||||
const endX = e.type.includes('mouse') ? e.clientX : e.changedTouches[0].clientX;
|
||||
const diff = endX - startX;
|
||||
const threshold = 50;
|
||||
|
||||
if (diff > threshold) {
|
||||
setCurrentIndex(prev => (prev > 0 ? prev - 1 : images.length - 1));
|
||||
} else if (diff < -threshold) {
|
||||
setCurrentIndex(prev => (prev + 1) % images.length);
|
||||
}
|
||||
|
||||
setAutoplay(true);
|
||||
};
|
||||
|
||||
const goToImage = (index) => {
|
||||
setCurrentIndex(index);
|
||||
setAutoplay(false);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
setCurrentIndex(prev => (prev === 0 ? images.length - 1 : prev - 1));
|
||||
setAutoplay(false);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
setCurrentIndex(prev => (prev + 1) % images.length);
|
||||
setAutoplay(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-5xl mx-auto select-none group">
|
||||
{/* Main Slider */}
|
||||
<div
|
||||
className="relative overflow-hidden h-auto rounded-2xl shadow-2xl"
|
||||
onMouseDown={handleDragStart}
|
||||
onMouseMove={handleDragMove}
|
||||
onMouseUp={handleDragEnd}
|
||||
onMouseLeave={handleDragEnd}
|
||||
onTouchStart={handleDragStart}
|
||||
onTouchMove={handleDragMove}
|
||||
onTouchEnd={handleDragEnd}
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
|
||||
>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="flex transition-transform duration-500 ease-in-out"
|
||||
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
|
||||
>
|
||||
{
|
||||
images.length > 0 && (
|
||||
images.map((img, idx) => (
|
||||
<div key={idx} className="w-full flex-shrink-0">
|
||||
<img src={`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com${img.image}`} alt={`Slide ${idx}`} className="relative w-full h-full" draggable={false} />
|
||||
<p className='absolute bottom-0 bg-black/70 lg:text-center text-[#FFF] z-40 w-full px-2 py-1 lg:py-2'>{img.desc}</p>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<div className="absolute bottom-4 left-4 z-30">
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="bg-black/50 hover:bg-black/70 text-white p-2 rounded-full text-lg shadow-lg transition-all border-2 border-[#fff}"
|
||||
title="Fullscreen"
|
||||
>
|
||||
<svg width="18px" height="18px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="#ffffff"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M21.7092 2.29502C21.8041 2.3904 21.8757 2.50014 21.9241 2.61722C21.9727 2.73425 21.9996 2.8625 22 2.997L22 3V9C22 9.55228 21.5523 10 21 10C20.4477 10 20 9.55228 20 9V5.41421L14.7071 10.7071C14.3166 11.0976 13.6834 11.0976 13.2929 10.7071C12.9024 10.3166 12.9024 9.68342 13.2929 9.29289L18.5858 4H15C14.4477 4 14 3.55228 14 3C14 2.44772 14.4477 2 15 2H20.9998C21.2749 2 21.5242 2.11106 21.705 2.29078L21.7092 2.29502Z" fill="#ffffff"></path> <path d="M10.7071 14.7071L5.41421 20H9C9.55228 20 10 20.4477 10 21C10 21.5523 9.55228 22 9 22H3.00069L2.997 22C2.74301 21.9992 2.48924 21.9023 2.29502 21.7092L2.29078 21.705C2.19595 21.6096 2.12432 21.4999 2.07588 21.3828C2.02699 21.2649 2 21.1356 2 21V15C2 14.4477 2.44772 14 3 14C3.55228 14 4 14.4477 4 15V18.5858L9.29289 13.2929C9.68342 12.9024 10.3166 12.9024 10.7071 13.2929C11.0976 13.6834 11.0976 14.3166 10.7071 14.7071Z" fill="#ffffff"></path> </g></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Arrows */}
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
className="absolute top-1/2 left-3 -translate-y-1/2 z-20 bg-black/40 hover:bg-black/70 text-white rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<svg width="32px" height="32px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M11.7071 16.7071C12.0976 16.3166 12.0976 15.6834 11.7071 15.2929L9.41421 13H17C17.5523 13 18 12.5523 18 12C18 11.4477 17.5523 11 17 11H9.41421L11.7071 8.70711C12.0976 8.31658 12.0976 7.68342 11.7071 7.29289C11.3166 6.90237 10.6834 6.90237 10.2929 7.29289L6.29289 11.2929C5.90237 11.6834 5.90237 12.3166 6.29289 12.7071L10.2929 16.7071C10.6834 17.0976 11.3166 17.0976 11.7071 16.7071Z" fill="#ffffff"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" fill="#ffffff"></path> </g></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 z-20 bg-black/40 hover:bg-black/70 text-white rounded-full flex items-center justify-center md:opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<svg width="32px" height="32px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M12.2929 16.7071C11.9024 16.3166 11.9024 15.6834 12.2929 15.2929L14.5858 13H7C6.44772 13 6 12.5523 6 12C6 11.4477 6.44772 11 7 11H14.5858L12.2929 8.70711C11.9024 8.31658 11.9024 7.68342 12.2929 7.29289C12.6834 6.90237 13.3166 6.90237 13.7071 7.29289L17.7071 11.2929C18.0976 11.6834 18.0976 12.3166 17.7071 12.7071L13.7071 16.7071C13.3166 17.0976 12.6834 17.0976 12.2929 16.7071Z" fill="#ffffff"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23ZM3 12C3 16.9706 7.02944 21 12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12Z" fill="#ffffff"></path> </g></svg>
|
||||
</button>
|
||||
|
||||
{/* Thumbnail Navigation */}
|
||||
<div className="mt-4 flex overflow-x-auto gap-2">
|
||||
{
|
||||
images.length > 0 && (
|
||||
images.map((img, idx) => (
|
||||
<img
|
||||
key={idx}
|
||||
src={`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com${img.image}`}
|
||||
alt={`Thumb ${idx}`}
|
||||
onClick={() => goToImage(idx)}
|
||||
className={`w-20 h-14 object-cover rounded-md cursor-pointer border-2 transition ${
|
||||
idx === currentIndex ? 'border-white' : 'border-transparent'
|
||||
}`}
|
||||
/>
|
||||
))
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Overlay */}
|
||||
{images.length > 0 && isFullscreen && (
|
||||
<div className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center">
|
||||
<img src={`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com${images[currentIndex].image}`} alt="Fullscreen" className="relative max-w-full max-h-[80vh] object-contain transition duration-300" />
|
||||
<p className='absolute bottom-0 bg-black/70 lg:text-center text-[#FFF] z-100 w-full px-2 py-1 lg:py-2'>{images[currentIndex].desc}</p>
|
||||
<button onClick={() => setIsFullscreen(false)} className="absolute top-4 left-4 text-white text-2xl bg-white/20 hover:bg-white/40 rounded-full" title="Close" >
|
||||
<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" fill="#000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <title>cross-circle</title> <desc>Created with Sketch Beta.</desc> <defs> </defs> <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"> <g id="Icon-Set" sketch:type="MSLayerGroup" transform="translate(-568.000000, -1087.000000)" fill="#ffffff"> <path d="M584,1117 C576.268,1117 570,1110.73 570,1103 C570,1095.27 576.268,1089 584,1089 C591.732,1089 598,1095.27 598,1103 C598,1110.73 591.732,1117 584,1117 L584,1117 Z M584,1087 C575.163,1087 568,1094.16 568,1103 C568,1111.84 575.163,1119 584,1119 C592.837,1119 600,1111.84 600,1103 C600,1094.16 592.837,1087 584,1087 L584,1087 Z M589.717,1097.28 C589.323,1096.89 588.686,1096.89 588.292,1097.28 L583.994,1101.58 L579.758,1097.34 C579.367,1096.95 578.733,1096.95 578.344,1097.34 C577.953,1097.73 577.953,1098.37 578.344,1098.76 L582.58,1102.99 L578.314,1107.26 C577.921,1107.65 577.921,1108.29 578.314,1108.69 C578.708,1109.08 579.346,1109.08 579.74,1108.69 L584.006,1104.42 L588.242,1108.66 C588.633,1109.05 589.267,1109.05 589.657,1108.66 C590.048,1108.27 590.048,1107.63 589.657,1107.24 L585.42,1103.01 L589.717,1098.71 C590.11,1098.31 590.11,1097.68 589.717,1097.28 L589.717,1097.28 Z" id="cross-circle" sketch:type="MSShapeGroup"> </path> </g> </g> </g></svg>
|
||||
</button>
|
||||
<button onClick={goToPrevious} className="absolute left-4 top-1/2 -translate-y-1/2 text-white text-3xl bg-white/10 hover:bg-white/30 rounded-full">
|
||||
<svg width="32px" height="32px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M11.7071 16.7071C12.0976 16.3166 12.0976 15.6834 11.7071 15.2929L9.41421 13H17C17.5523 13 18 12.5523 18 12C18 11.4477 17.5523 11 17 11H9.41421L11.7071 8.70711C12.0976 8.31658 12.0976 7.68342 11.7071 7.29289C11.3166 6.90237 10.6834 6.90237 10.2929 7.29289L6.29289 11.2929C5.90237 11.6834 5.90237 12.3166 6.29289 12.7071L10.2929 16.7071C10.6834 17.0976 11.3166 17.0976 11.7071 16.7071Z" fill="#ffffff"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C18.0751 23 23 18.0751 23 12C23 5.92487 18.0751 1 12 1C5.92487 1 1 5.92487 1 12C1 18.0751 5.92487 23 12 23ZM21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" fill="#ffffff"></path> </g></svg>
|
||||
</button>
|
||||
<button onClick={goToNext} className="absolute right-4 top-1/2 -translate-y-1/2 text-white text-3xl bg-white/10 hover:bg-white/30 rounded-full">
|
||||
<svg width="32px" height="32px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M12.2929 16.7071C11.9024 16.3166 11.9024 15.6834 12.2929 15.2929L14.5858 13H7C6.44772 13 6 12.5523 6 12C6 11.4477 6.44772 11 7 11H14.5858L12.2929 8.70711C11.9024 8.31658 11.9024 7.68342 12.2929 7.29289C12.6834 6.90237 13.3166 6.90237 13.7071 7.29289L17.7071 11.2929C18.0976 11.6834 18.0976 12.3166 17.7071 12.7071L13.7071 16.7071C13.3166 17.0976 12.6834 17.0976 12.2929 16.7071Z" fill="#ffffff"></path> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 23C5.92487 23 1 18.0751 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23ZM3 12C3 16.9706 7.02944 21 12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12Z" fill="#ffffff"></path> </g></svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageSlider;
|
||||
@@ -7,7 +7,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import Loader from "./ui/loader";
|
||||
interface AuthStatus {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
@@ -17,6 +18,7 @@ interface UserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
avatar?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -25,16 +27,18 @@ interface AuthResponse {
|
||||
token: string;
|
||||
record: UserRecord;
|
||||
}
|
||||
|
||||
|
||||
const LoginPage = () => {
|
||||
const [email, setEmail] = useState('suvodip@siliconpin.com');
|
||||
const [password, setPassword] = useState('Simple2pass');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [status, setStatus] = useState<AuthStatus>({ message: '', isError: false });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const pb = new PocketBase("https://tst-pb.s38.siliconpin.com");
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
const PUBLIC_HOST_API2_USERS = import.meta.env.PUBLIC_HOST_API2_USERS;
|
||||
const PUBLIC_POCKETBASE_URL = import.meta.env.PUBLIC_POCKETBASE_URL;
|
||||
|
||||
const pb = new PocketBase(PUBLIC_POCKETBASE_URL);
|
||||
|
||||
interface AuthResponse {
|
||||
token: string;
|
||||
@@ -43,10 +47,19 @@ const LoginPage = () => {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
type: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Set Coockie Function
|
||||
function setCookie(name : string, value : string, daysToExpire : number) {
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + (daysToExpire * 24 * 60 * 60 * 1000));
|
||||
const expires = "expires=" + date.toUTCString();
|
||||
document.cookie = `${name}=${value}; ${expires}; path=/`;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
@@ -54,6 +67,10 @@ const LoginPage = () => {
|
||||
|
||||
try {
|
||||
const authData = await pb.collection("users").authWithPassword(email, password);
|
||||
if (!authData?.token || !authData?.record) {
|
||||
throw new Error("Authentication failed: No token or user record received");
|
||||
}
|
||||
|
||||
const avatarUrl = authData.record.avatar ? pb.files.getUrl(authData.record, authData.record.avatar) : '';
|
||||
|
||||
const authResponse: AuthResponse = {
|
||||
@@ -62,17 +79,18 @@ const LoginPage = () => {
|
||||
query: 'new',
|
||||
id: authData.record.id,
|
||||
email: authData.record.email,
|
||||
name: authData.record.name || '',
|
||||
name: authData.record.name || authData.record.email.split('@')[0], // Fallback name
|
||||
type: authData.record.type || 'user',
|
||||
avatar: authData.record.avatar || ''
|
||||
}
|
||||
};
|
||||
|
||||
await syncSessionWithBackend(authResponse, avatarUrl);
|
||||
window.location.href = '/profile';
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Login failed:", error);
|
||||
setStatus({
|
||||
message: "Login failed. Please check your credentials.",
|
||||
message: error.message || "Login failed. Please check your credentials.",
|
||||
isError: true
|
||||
});
|
||||
} finally {
|
||||
@@ -86,7 +104,7 @@ const LoginPage = () => {
|
||||
setStatus({ message: '', isError: false });
|
||||
|
||||
const authData = await pb.collection('users').authWithOAuth2({ provider });
|
||||
|
||||
console.log('Authdata', authData);
|
||||
if (!authData?.record) {
|
||||
throw new Error("No user record found");
|
||||
}
|
||||
@@ -99,6 +117,7 @@ const LoginPage = () => {
|
||||
id: authData.record.id,
|
||||
email: authData.record.email || '',
|
||||
name: authData.record.name || '',
|
||||
type: authData.record.type || '',
|
||||
avatar: authData.record.avatar || ''
|
||||
}
|
||||
};
|
||||
@@ -115,18 +134,54 @@ const LoginPage = () => {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUserInMongo = async (siliconId: string) => {
|
||||
try {
|
||||
const response = await fetch(`${PUBLIC_HOST_API2_USERS}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
siliconId: siliconId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
if (response.status === 200 && data.message) {
|
||||
console.log('User exists:', data.message);
|
||||
return data.user; // return the existing user
|
||||
}
|
||||
console.log('New user created:', data);
|
||||
return data; // return the new user
|
||||
}
|
||||
|
||||
throw new Error(data.error || `HTTP error! status: ${response.status}`);
|
||||
} catch (error) {
|
||||
console.error('MongoDB Creation Error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const syncSessionWithBackend = async (authData: AuthResponse, avatarUrl: string) => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:2058/host-api/v1/users/session/', {
|
||||
// Step 1: Sync with SiliconPin backend
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include', // Important for cookies
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: 'new',
|
||||
accessToken: authData.token,
|
||||
email: authData.record.email,
|
||||
name: authData.record.name,
|
||||
type: authData.record.type,
|
||||
avatar: avatarUrl,
|
||||
isAuthenticated: true,
|
||||
id: authData.record.id
|
||||
@@ -134,17 +189,43 @@ const LoginPage = () => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to sync session');
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Session synced with backend:', data);
|
||||
} catch (error) {
|
||||
console.error('Error syncing session:', error);
|
||||
throw error; // Re-throw the error if you want calling functions to handle it
|
||||
console.log('Backend Sync Data:', data);
|
||||
|
||||
// Step 2: creating user in MongoDB
|
||||
await handleCreateUserInMongo(data.userData.userId);
|
||||
|
||||
// Step 3: Redirect to profile if all are ok
|
||||
window.location.href = '/profile';
|
||||
console.log('data', data)
|
||||
setCookie('token', data.userData.accessToken, 7);
|
||||
setCookie('user_name', authData.record.name, 7);
|
||||
setCookie('pb_id', authData.record.id, 7);
|
||||
setCookie('siliconId', data.userData.userId, 7);
|
||||
setCookie('isLogin', JSON.stringify(true), 7);
|
||||
|
||||
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('Sync Error:', error);
|
||||
throw new Error(`Session sync failed: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const { isLoggedIn, loading, error } = useIsLoggedIn();
|
||||
// console.log(isLoggedIn)
|
||||
if(loading){
|
||||
return <Loader />
|
||||
}
|
||||
if(isLoggedIn){
|
||||
return (
|
||||
// <></>
|
||||
window.location.href = '/'
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md shadow-lg rounded-xl overflow-hidden">
|
||||
@@ -263,6 +344,14 @@ const LoginPage = () => {
|
||||
>
|
||||
<img src="/assets/github.svg" alt="GitHub" className="h-6 w-6" />
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant="outline"
|
||||
onClick={() => loginWithOAuth2('gitea')}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<img src="/assets/gitea.svg" alt="GitHub" className="h-6 w-6" />
|
||||
</Button> */}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
|
||||
63
src/components/LoginOrProfile.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
|
||||
export default function LoginOrProfile({ deviceType }) {
|
||||
const { isLoggedIn, loading } = useIsLoggedIn();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
{
|
||||
deviceType === 'desktop' && (
|
||||
<p className="w-6 animate-pulse hover:text-[#6d9e37] transition-colors"></p>
|
||||
)
|
||||
}
|
||||
{
|
||||
deviceType === 'mobile' && (
|
||||
<svg className="animate-spin flex flex-col items-center justify-center w-[45px] h-[45px] text-[#6d9e37] hover:text-white transition-colors" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="64px" height="64px" viewBox="0 0 100 100" enable-background="new 0 0 100 100" xml:space="preserve" fill="#6d9e37"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <g id="Download_x5F_25_x25_"> </g> <g id="Download_x5F_50_x25_"> </g> <g id="Download_x5F_75_x25_"> </g> <g id="Download_x5F_100_x25_"> </g> <g id="Upload"> </g> <g id="Next"> </g> <g id="Last"> </g> <g id="OK"> </g> <g id="Fail"> </g> <g id="Add"> </g> <g id="Spinner_x5F_0_x25_"> </g> <g id="Spinner_x5F_25_x25_"> <g> <path fill="none" stroke="#6d9e37" stroke-width="4" stroke-linejoin="round" stroke-miterlimit="10" d="M50.188,26.812 c12.806,0,23.188,10.381,23.188,23.188"></path> <g> <circle cx="73.375" cy="50" r="1.959"></circle> <circle cx="72.029" cy="57.704" r="1.959"></circle> <circle cx="68.237" cy="64.579" r="1.959"></circle> <circle cx="62.324" cy="69.759" r="1.959"></circle> <circle cx="55.013" cy="72.699" r="1.959"></circle> </g> <g> <ellipse transform="matrix(0.9968 -0.0794 0.0794 0.9968 -4.0326 2.3121)" cx="27.045" cy="51.843" rx="1.959" ry="1.959"></ellipse> <ellipse transform="matrix(0.9968 -0.0794 0.0794 0.9968 -4.628 2.4912)" cx="28.998" cy="59.416" rx="1.959" ry="1.959"></ellipse> <ellipse transform="matrix(0.9968 -0.0794 0.0794 0.9968 -5.1348 2.8556)" cx="33.325" cy="65.967" rx="1.959" ry="1.959"></ellipse> <ellipse transform="matrix(0.9968 -0.0794 0.0794 0.9968 -5.4877 3.3713)" cx="39.63" cy="70.661" rx="1.959" ry="1.959"></ellipse> <ellipse transform="matrix(0.9968 -0.0794 0.0794 0.9968 -5.6506 3.9762)" cx="47.152" cy="73.012" rx="1.959" ry="1.959"></ellipse> </g> <g> <ellipse transform="matrix(0.9962 -0.0867 0.0867 0.9962 -3.713 2.567)" cx="27.71" cy="44.049" rx="1.959" ry="1.959"></ellipse> <ellipse transform="matrix(0.9962 -0.0867 0.0867 0.9962 -3.079 2.8158)" cx="30.892" cy="36.872" rx="1.959" ry="1.959"></ellipse> <ellipse transform="matrix(0.9962 -0.0867 0.0867 0.9962 -2.567 3.266)" cx="36.334" cy="31.199" rx="1.959" ry="1.959"></ellipse> <ellipse transform="matrix(0.9962 -0.0867 0.0867 0.9962 -2.2318 3.8617)" cx="43.363" cy="27.636" rx="1.959" ry="1.959"></ellipse> </g> </g> </g> <g id="Spinner_x5F_50_x25_"> </g> <g id="Spinner_x5F_75_x25_"> </g> <g id="Brightest_x5F_25_x25_"> </g> <g id="Brightest_x5F_50_x25_"> </g> <g id="Brightest_x5F_75_x25_"> </g> <g id="Brightest_x5F_100_x25_"> </g> <g id="Reload"> </g> <g id="Forbidden"> </g> <g id="Clock"> </g> <g id="Compass"> </g> <g id="World"> </g> <g id="Speed"> </g> <g id="Microphone"> </g> <g id="Options"> </g> <g id="Chronometer"> </g> <g id="Lock"> </g> <g id="User"> </g> <g id="Position"> </g> <g id="No_x5F_Signal"> </g> <g id="Low_x5F_Signal"> </g> <g id="Mid_x5F_Signal"> </g> <g id="High_x5F_Signal"> </g> <g id="Options_1_"> </g> <g id="Flash"> </g> <g id="No_x5F_Signal_x5F_02"> </g> <g id="Low_x5F_Signal_x5F_02"> </g> <g id="Mid_x5F_Signal_x5F_02"> </g> <g id="High_x5F_Signal_x5F_02"> </g> <g id="Favorite"> </g> <g id="Search"> </g> <g id="Stats_x5F_01"> </g> <g id="Stats_x5F_02"> </g> <g id="Turn_x5F_On_x5F_Off"> </g> <g id="Full_x5F_Height"> </g> <g id="Full_x5F_Width"> </g> <g id="Full_x5F_Screen"> </g> <g id="Compress_x5F_Screen"> </g> <g id="Chat"> </g> <g id="Bluetooth"> </g> <g id="Share_x5F_iOS"> </g> <g id="Share_x5F_Android"> </g> <g id="Love__x2F__Favorite"> </g> <g id="Hamburguer"> </g> <g id="Flying"> </g> <g id="Take_x5F_Off"> </g> <g id="Land"> </g> <g id="City"> </g> <g id="Nature"> </g> <g id="Pointer"> </g> <g id="Prize"> </g> <g id="Extract"> </g> <g id="Play"> </g> <g id="Pause"> </g> <g id="Stop"> </g> <g id="Forward"> </g> <g id="Reverse"> </g> <g id="Next_1_"> </g> <g id="Last_1_"> </g> <g id="Empty_x5F_Basket"> </g> <g id="Add_x5F_Basket"> </g> <g id="Delete_x5F_Basket"> </g> <g id="Error_x5F_Basket"> </g> <g id="OK_x5F_Basket"> </g> </g></svg>
|
||||
)
|
||||
}
|
||||
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<>
|
||||
{deviceType === 'desktop' && (
|
||||
<a href="/login" className="hover:text-[#6d9e37] transition-colors">Login</a>
|
||||
)}
|
||||
{deviceType === 'mobile' && (
|
||||
<a href="/login" className="flex flex-col items-center justify-center w-full h-full text-[#6d9e37] hover:text-white transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
<path d="M7 20.662V19c0-1.18.822-2.2 2-2.5a5.5 5.5 0 0 1 6 0c1.178.3 2 1.323 2 2.5v1.662"></path>
|
||||
</svg>
|
||||
<span className="text-xs mt-1">Login</span>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{deviceType === 'desktop' && (
|
||||
<a href="/profile" className="hover:text-[#6d9e37] transition-colors">Profile</a>
|
||||
)}
|
||||
{deviceType === 'mobile' && (
|
||||
<a href="/profile" className="flex flex-col items-center justify-center w-full h-full text-[#6d9e37] hover:text-white transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<circle cx="12" cy="10" r="3"></circle>
|
||||
<path d="M7 20.662V19c0-1.18.822-2.2 2-2.5a5.5 5.5 0 0 1 6 0c1.178.3 2 1.323 2 2.5v1.662"></path>
|
||||
</svg>
|
||||
<span className="text-xs mt-1">Profile</span>
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
324
src/components/MakePayment.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import QRCode from "react-qr-code";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle} from "./ui/dialog";
|
||||
import { Toast } from './Toast';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "./ui/card";
|
||||
import { FileX, Copy, CheckCircle2, AlertCircle, X } from "lucide-react";
|
||||
import Loader from "./ui/loader";
|
||||
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
|
||||
export default function MakePayment(){
|
||||
const [initialOrderData, setInitialOrderData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showQRModal, setShowQRModal] = useState(false);
|
||||
const [showHelpMessage, setShowHelpMessage] = useState(false);
|
||||
const [transactionId, setTransactionId] = useState('');
|
||||
const [orderIdInput, setOrderIdInput] = useState('');
|
||||
const [upiPaymentLink, setUpiPaymentLink] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [getOrderId, setGetOrderId] = useState();
|
||||
const [saveUpiResponse, setSaveUpiResponse] = useState({ message: '', visible: false, isSuccess: false });
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const orderId = urlParams.get('orderId');
|
||||
|
||||
if (!orderId) {
|
||||
setError('Order ID is missing from URL');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setGetOrderId(orderId);
|
||||
const getInitialOrderData = () => {
|
||||
setIsLoading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('order_id', orderId);
|
||||
|
||||
fetch(`${PUBLIC_USER_API_URL}?query=get-initiated_payment`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || 'Failed to initialize payment');
|
||||
}
|
||||
setInitialOrderData(data);
|
||||
setError(null);
|
||||
|
||||
// Generate UPI payment link when data is loaded
|
||||
if (data.payment_data?.amount) {
|
||||
const amount = data.payment_data.amount;
|
||||
const upiLink = generateUPILink(amount, data.txn_id);
|
||||
setUpiPaymentLink(upiLink);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
setError(error.message || 'Payment failed. Please try again.');
|
||||
console.error('An error occurred:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
getInitialOrderData();
|
||||
}, []);
|
||||
|
||||
function generateUPILink(amount, transactionId) {
|
||||
// Replace these with your actual merchant details
|
||||
const merchantUPI = "7001601485@okbizaxis";
|
||||
const merchantName = "SiliconPin";
|
||||
const currency = "INR";
|
||||
|
||||
// Encode parameters for URL
|
||||
const encodedMerchantName = encodeURIComponent(merchantName);
|
||||
const transactionNote = encodeURIComponent(`Payment for order #${transactionId}`);
|
||||
|
||||
// Construct UPI payment link
|
||||
return `upi://pay?pa=${merchantUPI}&pn=${encodedMerchantName}&am=${amount}&cu=${currency}&tn=${transactionNote}`;
|
||||
}
|
||||
// waiting payment update
|
||||
// Payment update not recieved if
|
||||
function redirectToPayU() {
|
||||
if (!initialOrderData?.payment_data || !initialOrderData.payment_url) {
|
||||
console.error('Payment data not loaded yet');
|
||||
alert('Payment information is not ready. Please wait.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a form dynamically
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = initialOrderData.payment_url;
|
||||
form.style.display = 'none';
|
||||
|
||||
Object.entries(initialOrderData.payment_data).forEach(([key, value]) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = key;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
console.log(`Adding field: ${key}=${value}`);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
if (showQRModal) {
|
||||
setShowHelpMessage(false);
|
||||
timer = setTimeout(() => {
|
||||
setShowHelpMessage(true);
|
||||
}, 2000);
|
||||
}
|
||||
return () => clearTimeout(timer); // Cleanup on unmount or when modal closes
|
||||
}, [showQRModal]);
|
||||
|
||||
function handleQRPaymentClick() {
|
||||
if (!upiPaymentLink) {
|
||||
alert('Payment information is not ready. Please wait.');
|
||||
return;
|
||||
}
|
||||
setShowQRModal(true);
|
||||
}
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(getOrderId)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000); // Reset after 1 second
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to copy text: ', err);
|
||||
});
|
||||
};
|
||||
const handlePayPalPayment = () => {
|
||||
const rawInrAmount = initialOrderData.payment_data?.amount || 0;
|
||||
const exchangeRate = 80;
|
||||
const convertedUSD = rawInrAmount / exchangeRate;
|
||||
const usdAmount = Math.max(convertedUSD, 1).toFixed(2);
|
||||
const paypalUrl = `https://www.paypal.com/paypalme/dwdconsultancy/${usdAmount}`;
|
||||
window.open(paypalUrl, '_blank');
|
||||
};
|
||||
|
||||
const handleSaveUPIPayment = async () => {
|
||||
try {
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=save-upi-payment`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
orderId: orderIdInput,
|
||||
transactionId: transactionId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success === true) {
|
||||
setSaveUpiResponse({
|
||||
message: `Transaction ID ${transactionId} saved. We'll contact you shortly.`,
|
||||
visible: true,
|
||||
isSuccess: true
|
||||
});
|
||||
setShowHelpMessage(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setSaveUpiResponse({
|
||||
message: 'Failed to save payment details. Please try again.',
|
||||
visible: true,
|
||||
isSuccess: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if(!initialOrderData){
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-grow py-10 text-center px-4 text-gray-600 mt-20">
|
||||
<FileX className="mb-4 text-gray-500" size={100} />
|
||||
<p className="text-lg"> No bill was found. <a className="text-[#6d9e37] font-medium hover:underline" href="/profile"> Click here </a> to go to your profile.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="flex items-center justify-center min-h-screen">
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen p-4">
|
||||
<Card className="max-w-md w-full bg-white p-6 rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-4">Complete Your Payment</h1>
|
||||
|
||||
{initialOrderData && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="font-bold">Order ID:</span>
|
||||
<span>{initialOrderData.txn_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Amount:</span>
|
||||
<span>₹{initialOrderData.payment_data?.amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<span className=""><strong>Pay Using:</strong></span>
|
||||
<hr className="border-b border-b-[#6d9e37] mb-4" />
|
||||
|
||||
<Button className="" onClick={handleQRPaymentClick}>UPI QR Code</Button>
|
||||
<span className="text-center mt-2 text-gray-400 text-xs">Banking Service by DWD Consultancy Services (0% Processing Fee)</span>
|
||||
|
||||
<div className="flex items-center my-2">
|
||||
<div className="flex-grow border-t border-gray-500"></div>
|
||||
<span className="mx-2 text-gray-500 font-semibold">OR</span>
|
||||
<div className="flex-grow border-t border-gray-500"></div>
|
||||
</div>
|
||||
|
||||
<Button className="text-[#414042] font-bold border-[2px] border-[#00ad7d] hover:bg-[#00ad7d]/80" onClick={redirectToPayU} variant="outline">
|
||||
Pay with
|
||||
<img className="w-[50px]" src="/assets/payu-logo.svg" alt="" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4 text-[#414042] font-bold border-[2px] border-b-[#003087] border-r-[#003087] border-t-[#009CDE] border-l-[#009CDE] hover:bg-gradient-to-r hover:from-[#009CDE] hover:to-[#003087]"
|
||||
onClick={handlePayPalPayment}
|
||||
>
|
||||
Pay with
|
||||
<img className="w-[50px]" src="/assets/paypal-logo-white.svg" alt="PayPal" />
|
||||
</Button>
|
||||
{/* <span className="text-center mt-2 text-gray-400 text-xs">2% Payment Gateway Charge</span> */}
|
||||
|
||||
{/* Add this divider and PayPal button */}
|
||||
{/* <div className="flex items-center my-2">
|
||||
<div className="flex-grow border-t border-gray-500"></div>
|
||||
<span className="mx-2 text-gray-500 font-semibold">OR</span>
|
||||
<div className="flex-grow border-t border-gray-500"></div>
|
||||
</div> */}
|
||||
|
||||
|
||||
{/* <span className="text-center mt-2 text-gray-400 text-xs">3.5% Payment Processing Fee</span> */}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* QR Code Modal */}
|
||||
<Dialog open={showQRModal} onOpenChange={(open) => {setShowQRModal(open); if (!open) {setShowHelpMessage(false);}}}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scan QR Code to Pay</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="p-4 bg-white rounded-lg border border-gray-200">
|
||||
<QRCode value={upiPaymentLink} size={256} level="H" bgColor="#ffffff" fgColor="#000000" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 text-center">
|
||||
Scan this QR code with any UPI app to complete your payment
|
||||
</p>
|
||||
<Button variant="outline" onClick={handleCopy} className="text-sm" >{copied ? 'Copied!' : getOrderId} {!copied && <Copy size={15} />}</Button>
|
||||
|
||||
{showHelpMessage && (
|
||||
<div className="w-full mt-4 p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<p className="text-sm text-yellow-800 mb-3">
|
||||
If you haven't received payment confirmation, please share your Order Id & transaction ID with us.
|
||||
</p>
|
||||
<Input type="text" value={orderIdInput} onChange={(e) => setOrderIdInput(e.target.value)} placeholder="Enter your Order ID" className="w-full p-2 border border-gray-300 rounded-md text-sm mb-2" />
|
||||
<Input type="text" value={transactionId} onChange={(e) => setTransactionId(e.target.value)} placeholder="Enter your transaction ID" className="w-full p-2 border border-gray-300 rounded-md text-sm mb-2" />
|
||||
<Button onClick={handleSaveUPIPayment} className="w-full text-sm">Save Transaction ID</Button>
|
||||
</div>
|
||||
)}
|
||||
{saveUpiResponse.visible && (
|
||||
<div className={`fixed bottom-0 z-50 flex items-center justify-center`}>
|
||||
<div className={`relative p-6 rounded-lg shadow-lg max-w-xs md:max-w-md w-full ${saveUpiResponse.isSuccess ? 'bg-green-50 border border-green-200 text-green-800' : 'bg-red-50 border border-red-200 text-red-800'} animate-fade-in-up`}>
|
||||
<button
|
||||
onClick={() => setSaveUpiResponse(prev => ({...prev, visible: false}))}
|
||||
className={`absolute top-3 right-3 p-1 rounded-full ${saveUpiResponse.isSuccess ? 'hover:bg-green-100 text-green-500' : 'hover:bg-red-100 text-red-500'} transition-colors`}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center text-center gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{saveUpiResponse.isSuccess ? (
|
||||
<CheckCircle2 className="w-6 h-6 text-green-500" />
|
||||
) : (
|
||||
<AlertCircle className="w-6 h-6 text-red-500" />
|
||||
)}
|
||||
<p className="text-base font-medium">{saveUpiResponse.message}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => window.location.href = '/'} className={saveUpiResponse.isSuccess ? 'border-green-300' : 'border-red-300'}>Back to Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Transaction ID ${transactionId} saved. We'll contact you shortly.
|
||||
591
src/components/Manager/AllCustomerList.jsx
Normal file
@@ -0,0 +1,591 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
import Loader from "../../components/ui/loader";
|
||||
import { ChevronUp, ChevronDown, Eye, Pencil, Trash2, Download, CircleArrowRight } from "lucide-react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
export default function AllCustomerList() {
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
const [usersData, setUsersData] = useState([]);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: 'asc' });
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 10;
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && sessionData?.user_type === 'admin') {
|
||||
fetchUsersData();
|
||||
}
|
||||
}, [isLoggedIn, sessionData]);
|
||||
|
||||
const fetchUsersData = async () => {
|
||||
setDataLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${PUBLIC_USER_API_URL}?query=get-all-users`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setUsersData(data.data || []);
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const requestSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const handleEdit = (user) => {
|
||||
setCurrentUser(user);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
||||
const handleViewUser = (user) => {
|
||||
setCurrentUser(user);
|
||||
setIsViewModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = (user) => {
|
||||
setCurrentUser(user);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
const res = await fetch(`${PUBLIC_USER_API_URL}${currentUser.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
|
||||
// Refresh the user list
|
||||
await fetchUsersData();
|
||||
setIsDeleteModalOpen(false);
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserChanges = async (updatedUser) => {
|
||||
try {
|
||||
const res = await fetch(`${PUBLIC_USER_API_URL}${updatedUser.id}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updatedUser)
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
|
||||
// Refresh the user list
|
||||
await fetchUsersData();
|
||||
setIsEditModalOpen(false);
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredData = usersData.filter(user => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
user.name.toLowerCase().includes(searchLower) ||
|
||||
user.email.toLowerCase().includes(searchLower) ||
|
||||
user.siliconId.toLowerCase().includes(searchLower) ||
|
||||
user.pbId.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
|
||||
const sortedData = [...filteredData].sort((a, b) => {
|
||||
if (sortConfig.key) {
|
||||
if (a[sortConfig.key] < b[sortConfig.key]) {
|
||||
return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
}
|
||||
if (a[sortConfig.key] > b[sortConfig.key]) {
|
||||
return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Pagination logic
|
||||
const totalPages = Math.ceil(sortedData.length / itemsPerPage);
|
||||
const currentItems = sortedData.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
const paginate = (pageNumber) => setCurrentPage(pageNumber);
|
||||
|
||||
const getPaginationRange = () => {
|
||||
const totalPageCount = totalPages;
|
||||
const currentPageNum = currentPage;
|
||||
const siblingCount = 1;
|
||||
const DOTS = '...';
|
||||
|
||||
// Pages count is determined as siblingCount + firstPage + lastPage + currentPage + 2*DOTS
|
||||
const totalPageNumbers = siblingCount + 5;
|
||||
|
||||
if (totalPageNumbers >= totalPageCount) {
|
||||
return range(1, totalPageCount);
|
||||
}
|
||||
|
||||
const leftSiblingIndex = Math.max(currentPageNum - siblingCount, 1);
|
||||
const rightSiblingIndex = Math.min(
|
||||
currentPageNum + siblingCount,
|
||||
totalPageCount
|
||||
);
|
||||
|
||||
const shouldShowLeftDots = leftSiblingIndex > 2;
|
||||
const shouldShowRightDots = rightSiblingIndex < totalPageCount - 2;
|
||||
|
||||
const firstPageIndex = 1;
|
||||
const lastPageIndex = totalPageCount;
|
||||
|
||||
if (!shouldShowLeftDots && shouldShowRightDots) {
|
||||
let leftItemCount = 3 + 2 * siblingCount;
|
||||
let leftRange = range(1, leftItemCount);
|
||||
|
||||
return [...leftRange, DOTS, totalPageCount];
|
||||
}
|
||||
|
||||
if (shouldShowLeftDots && !shouldShowRightDots) {
|
||||
let rightItemCount = 3 + 2 * siblingCount;
|
||||
let rightRange = range(
|
||||
totalPageCount - rightItemCount + 1,
|
||||
totalPageCount
|
||||
);
|
||||
return [firstPageIndex, DOTS, ...rightRange];
|
||||
}
|
||||
|
||||
if (shouldShowLeftDots && shouldShowRightDots) {
|
||||
let middleRange = range(leftSiblingIndex, rightSiblingIndex);
|
||||
return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex];
|
||||
}
|
||||
};
|
||||
|
||||
const range = (start, end) => {
|
||||
let length = end - start + 1;
|
||||
return Array.from({ length }, (_, idx) => idx + start);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric' };
|
||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
||||
};
|
||||
|
||||
if (loading || dataLoading) return <Loader />;
|
||||
if (error || apiError) return <p>Error: {error?.message || apiError}</p>;
|
||||
if (!isLoggedIn || sessionData?.user_type !== 'admin') {
|
||||
return <p className="text-center mt-8">You are not authorized to view this page. <a href="/" className="text-[#6d9e37]">Click Here</a> to go to the homepage.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<h1 className="text-2xl font-bold">Customer Management</h1>
|
||||
<div className="relative w-full md:w-64">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search customers..."
|
||||
className="w-full pl-4 pr-10 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-[#6d9e37]"
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<svg
|
||||
className="absolute right-3 top-2.5 h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('siliconId')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Silicon ID
|
||||
{sortConfig.key === 'siliconId' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('name')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
{sortConfig.key === 'name' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('email')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Email
|
||||
{sortConfig.key === 'email' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('pbId')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
PB ID
|
||||
{sortConfig.key === 'pbId' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('type')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Type
|
||||
{sortConfig.key === 'type' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('created_at')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Created At
|
||||
{sortConfig.key === 'created_at' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentItems.length > 0 ? (
|
||||
currentItems.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{user.siliconId}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.email}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{user.pbId}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${user.type === 'admin' ? 'bg-purple-100 text-purple-800' : 'bg-blue-100 text-blue-800' }`}>
|
||||
{user.type.charAt(0).toUpperCase() + user.type.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="inline-flex px-6 py-4 space-x-4 whitespace-nowrap text-sm font-medium">
|
||||
<button onClick={() => window.open(`/manager/view-as?siliconId=${user.siliconId}`)} title={`View as ${user.name}`}>
|
||||
<CircleArrowRight className="text-[#6d9e37] w-5 h-5 hover:bg-gray-700 rounded-full transition-all transform hover:scale-105 duration-500" />
|
||||
</button>
|
||||
<button title="View User" onClick={() => {handleViewUser(user)}} className="text-indigo-600 hover:text-indigo-900">
|
||||
<Eye className="w-5 h-5" />
|
||||
</button>
|
||||
<button title="Edit User" onClick={() => handleEdit(user)} className="text-yellow-600 hover:text-yellow-900">
|
||||
<Pencil className="w-5 h-5" />
|
||||
</button>
|
||||
<button title="Delete User" onClick={() => handleDelete(user)} className="text-red-600 hover:text-red-900">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="7" className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
No customers found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortedData.length > itemsPerPage && (
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentPage > 1 && (
|
||||
<>
|
||||
<Button onClick={() => paginate(1)} disabled={currentPage === 1} variant="outline" size="sm">First</Button>
|
||||
<Button onClick={() => paginate(currentPage - 1)} disabled={currentPage === 1} variant="outline" size="sm">Previous</Button>
|
||||
</>
|
||||
)}
|
||||
{getPaginationRange().map((item, index) => {
|
||||
if (item === '...') {
|
||||
return (
|
||||
<span key={`ellipsis-${index}`} className="px-2 py-1">...</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button key={`page-${item}`} onClick={() => paginate(item)} variant={currentPage === item ? "default" : "outline"} size="sm">{item}</Button>
|
||||
);
|
||||
})}
|
||||
{currentPage < totalPages && (
|
||||
<>
|
||||
<Button onClick={() => paginate(currentPage + 1)} disabled={currentPage === totalPages} variant="outline" size="sm">Next</Button>
|
||||
<Button onClick={() => paginate(totalPages)} disabled={currentPage === totalPages} variant="outline" size="sm">Last</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{isEditModalOpen && currentUser && (
|
||||
<Dialog open={isEditModalOpen} onOpenChange={setIsEditModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User</DialogTitle>
|
||||
<DialogDescription>
|
||||
Make changes to the user profile here. Click save when you're done.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
className="col-span-3 border rounded-md px-3 py-2"
|
||||
value={currentUser?.name || ''}
|
||||
onChange={(e) => setCurrentUser({...currentUser, name: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<label htmlFor="email" className="text-right">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
className="col-span-3 border rounded-md px-3 py-2"
|
||||
value={currentUser?.email || ''}
|
||||
onChange={(e) => setCurrentUser({...currentUser, email: e.target.value})}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<label htmlFor="type" className="text-right">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
className="col-span-3 border rounded-md px-3 py-2"
|
||||
value={currentUser?.type || 'user'}
|
||||
onChange={(e) => setCurrentUser({...currentUser, type: e.target.value})}
|
||||
>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="user">User</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setIsEditModalOpen(false)}>Cancel</Button>
|
||||
<Button type="button" onClick={() => {saveUserChanges(currentUser); setIsEditModalOpen(false);}}>Save changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* View User Modal */}
|
||||
{isViewModalOpen && currentUser && (
|
||||
<Dialog open={isViewModalOpen} onOpenChange={setIsViewModalOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl">User Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Detailed information about {currentUser.name}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-5 items-center gap-4">
|
||||
<div className="col-span-2 text-right font-medium text-gray-500">
|
||||
Avatar
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
{currentUser.avatar ? (
|
||||
<img src={currentUser.avatar} alt={`${currentUser.name}'s avatar`} className="h-16 w-16 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="h-16 w-16 rounded-full bg-gray-200 flex items-center text-center text-sm justify-center text-gray-500">
|
||||
No Image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 items-center gap-4">
|
||||
<div className="col-span-2 text-right font-medium text-gray-500">
|
||||
Full Name
|
||||
</div>
|
||||
<div className="col-span-3 font-medium">
|
||||
{currentUser.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 items-center gap-4">
|
||||
<div className="col-span-2 text-right font-medium text-gray-500">
|
||||
Email
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<a
|
||||
href={`mailto:${currentUser.email}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{currentUser.email}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 items-center gap-4">
|
||||
<div className="col-span-2 text-right font-medium text-gray-500">
|
||||
User Type
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
{currentUser.type && (
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
currentUser.type === 'admin'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{currentUser.type.charAt(0).toUpperCase() + currentUser.type.slice(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 items-center gap-4">
|
||||
<div className="col-span-2 text-right font-medium text-gray-500">
|
||||
Silicon ID
|
||||
</div>
|
||||
<div className="col-span-3 font-mono">
|
||||
{currentUser.siliconId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 items-center gap-4">
|
||||
<div className="col-span-2 text-right font-medium text-gray-500">
|
||||
PB ID
|
||||
</div>
|
||||
<div className="col-span-3 font-mono">
|
||||
{currentUser.pbId}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 items-center gap-4">
|
||||
<div className="col-span-2 text-right font-medium text-gray-500">
|
||||
Account Created
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
{formatDate(currentUser.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsViewModalOpen(false)}
|
||||
className="mt-4"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen && currentUser && (
|
||||
<Dialog open={isDeleteModalOpen} onOpenChange={setIsDeleteModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete user {currentUser?.name} ({currentUser?.email})?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDeleteModalOpen(false)}>Cancel</Button>
|
||||
<Button onClick={() => {confirmDelete();setIsDeleteModalOpen(false);}}>Delete</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
860
src/components/Manager/AllSellingList.jsx
Normal file
@@ -0,0 +1,860 @@
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
import Loader from "../../components/ui/loader";
|
||||
import { PDFDownloadLink } from '@react-pdf/renderer';
|
||||
import InvoicePDF from "../../lib/InvoicePDF";
|
||||
import { Eye, Pencil, Trash2, Download, ChevronUp, ChevronDown, Search, ArrowLeft, FileText, ArrowRight } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export default function AllSellingList() {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
const [billingData, setBillingData] = useState([]);
|
||||
const [usersData, setUsersData] = useState([]);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [apiError, setApiError] = useState(null);
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [formData, setFormData] = useState({ service: '', serviceId: 'service-1', cycle: 'monthly', amount: '', user: '', siliconId: '', name: '', status: 'pending', remarks: '' });
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState(null);
|
||||
const [customerSearchTerm, setCustomerSearchTerm] = useState('');
|
||||
|
||||
// Sorting state
|
||||
const [sortConfig, setSortConfig] = useState({ key: 'created_at', direction: 'desc' });
|
||||
|
||||
// Filtering state
|
||||
const [filters, setFilters] = useState({ status: '', cycle: '', service: '' });
|
||||
|
||||
// Search state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// Pagination state
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage] = useState(10);
|
||||
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && sessionData?.user_type === 'admin') {
|
||||
fetchBillingData();
|
||||
fetchUsersData();
|
||||
}
|
||||
}, [isLoggedIn, sessionData]);
|
||||
|
||||
const fetchBillingData = async () => {
|
||||
try {
|
||||
setDataLoading(true);
|
||||
const res = await fetch(`${PUBLIC_USER_API_URL}?query=all-selling-list`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setBillingData(data.data || []);
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsersData = async () => {
|
||||
try {
|
||||
const res = await fetch(`${PUBLIC_USER_API_URL}?query=get-all-users`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setUsersData(data.data || []);
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter customers based on search term
|
||||
const filteredCustomers = useMemo(() => {
|
||||
return usersData.filter(user =>
|
||||
user.name.toLowerCase().includes(customerSearchTerm.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(customerSearchTerm.toLowerCase()) ||
|
||||
user.siliconId.toLowerCase().includes(customerSearchTerm.toLowerCase())
|
||||
);
|
||||
}, [usersData, customerSearchTerm]);
|
||||
|
||||
const handleViewItem = (item) => {
|
||||
setSelectedItem(item);
|
||||
setEditMode(false);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditItem = (item) => {
|
||||
setSelectedItem(item);
|
||||
setFormData({
|
||||
service: item.service,
|
||||
serviceId: item.serviceId,
|
||||
cycle: item.cycle,
|
||||
amount: item.amount,
|
||||
user: item.user,
|
||||
siliconId: item.siliconId,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
remarks: item.remarks,
|
||||
billing_date: item.billing_date
|
||||
});
|
||||
setEditMode(true);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteItem = async (billingId, serviceType) => {
|
||||
if (!window.confirm('Are you sure you want to delete this billing record?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${PUBLIC_USER_API_URL}?query=delete-billing&billingId=${billingId}&serviceType=${serviceType}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
fetchBillingData();
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
setSelectedItem(null);
|
||||
setFormData({ service: '', serviceId: 'service-2', cycle: 'monthly', amount: '', user: '', siliconId: '', name: '', status: 'pending', remarks: '', billing_date: new Date().toISOString().split('T')[0] });
|
||||
setEditMode(true);
|
||||
setCurrentStep(1);
|
||||
setSelectedCustomer(null);
|
||||
setCustomerSearchTerm('');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const url = selectedItem ? `${PUBLIC_USER_API_URL}?query=update-billing&id=${selectedItem.id}` : `${PUBLIC_USER_API_URL}?query=create-billing`;
|
||||
|
||||
const method = selectedItem ? 'PUT' : 'POST';
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
||||
fetchBillingData();
|
||||
setDialogOpen(false);
|
||||
setCurrentStep(1);
|
||||
} catch (err) {
|
||||
setApiError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCustomerSelect = (customer) => {
|
||||
setSelectedCustomer(customer);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
user: customer.email,
|
||||
siliconId: customer.siliconId,
|
||||
name: customer.name
|
||||
}));
|
||||
setCurrentStep(2);
|
||||
};
|
||||
|
||||
const handleBackToCustomerSelect = () => {
|
||||
setCurrentStep(1);
|
||||
setSelectedCustomer(null);
|
||||
};
|
||||
|
||||
// Export to Excel function
|
||||
const exportToExcel = () => {
|
||||
const worksheet = XLSX.utils.json_to_sheet(filteredAndSortedData.map(item => ({
|
||||
'Billing ID': item.billing_id,
|
||||
'Service': item.service,
|
||||
'Customer Name': item.name,
|
||||
'Customer Email': item.user,
|
||||
'Silicon ID': item.siliconId,
|
||||
'Amount': item.amount,
|
||||
'cycle': item.cycle,
|
||||
'Status': item.status,
|
||||
'Service Status': item.service_status,
|
||||
'Created At': formatDate(item.created_at),
|
||||
'Remarks': item.remarks
|
||||
})));
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, "Billing Data");
|
||||
XLSX.writeFile(workbook, `billing_data_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
};
|
||||
|
||||
// Sorting functionality
|
||||
const requestSort = (key) => {
|
||||
let direction = 'asc';
|
||||
if (sortConfig.key === key && sortConfig.direction === 'asc') {
|
||||
direction = 'desc';
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Filter and sort data
|
||||
const filteredAndSortedData = useMemo(() => {
|
||||
let filteredData = [...billingData];
|
||||
|
||||
if (searchTerm) {
|
||||
filteredData = filteredData.filter(item =>
|
||||
Object.values(item).some(
|
||||
val => val && val.toString().toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
filteredData = filteredData.filter(item => item.status === filters.status);
|
||||
}
|
||||
if (filters.cycle) {
|
||||
filteredData = filteredData.filter(item => item.cycle === filters.cycle);
|
||||
}
|
||||
if (filters.service) {
|
||||
filteredData = filteredData.filter(item => item.service === filters.service);
|
||||
}
|
||||
|
||||
if (sortConfig.key) {
|
||||
filteredData.sort((a, b) => {
|
||||
if (a[sortConfig.key] < b[sortConfig.key]) {
|
||||
return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
}
|
||||
if (a[sortConfig.key] > b[sortConfig.key]) {
|
||||
return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return filteredData;
|
||||
}, [billingData, searchTerm, filters, sortConfig]);
|
||||
|
||||
const currentItems = useMemo(() => {
|
||||
const indexOfLastItem = currentPage * itemsPerPage;
|
||||
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
|
||||
return filteredAndSortedData.slice(indexOfFirstItem, indexOfLastItem);
|
||||
}, [currentPage, itemsPerPage, filteredAndSortedData]);
|
||||
|
||||
const totalPages = Math.ceil(filteredAndSortedData.length / itemsPerPage);
|
||||
|
||||
const paginate = (pageNumber) => {
|
||||
if (pageNumber > 0 && pageNumber <= totalPages) {
|
||||
setCurrentPage(pageNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const getPaginationRange = () => {
|
||||
const range = [];
|
||||
const maxVisiblePages = 5;
|
||||
range.push(1);
|
||||
if (currentPage > 3) {
|
||||
range.push('...');
|
||||
}
|
||||
let start = Math.max(2, currentPage - 1);
|
||||
let end = Math.min(totalPages - 1, currentPage + 1);
|
||||
if (currentPage <= 3) {
|
||||
end = Math.min(4, totalPages - 1);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
start = Math.max(totalPages - 3, 2);
|
||||
}
|
||||
for (let i = start; i <= end; i++) {
|
||||
if (i > 1 && i < totalPages) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
if (currentPage < totalPages - 2) {
|
||||
range.push('...');
|
||||
}
|
||||
if (totalPages > 1) {
|
||||
range.push(totalPages);
|
||||
}
|
||||
return range;
|
||||
};
|
||||
|
||||
const uniqueServices = [...new Set(billingData.map(item => item.service))];
|
||||
const uniqueStatuses = ['completed', 'pending', 'failed'];
|
||||
const uniquecycles = ['monthly', 'yearly', 'one-time'];
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
status: '',
|
||||
cycle: '',
|
||||
service: ''
|
||||
});
|
||||
setSearchTerm('');
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleFilterChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = (e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'completed': return 'bg-green-100 text-green-800';
|
||||
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed': return 'bg-red-100 text-red-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
|
||||
return new Date(dateString).toLocaleDateString(undefined, options);
|
||||
};
|
||||
|
||||
const formatCurrency = (amount) => {
|
||||
if (!amount) return '$0.00';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(parseFloat(amount));
|
||||
};
|
||||
|
||||
if (loading || (isLoggedIn && sessionData?.user_type === 'admin' && dataLoading)) {
|
||||
return <Loader />;
|
||||
}
|
||||
if (error || apiError) return <p>Error: {error?.message || apiError.message}</p>;
|
||||
if (!isLoggedIn || sessionData?.user_type !== 'admin') {
|
||||
return <p className="text-center mt-8">You are not authorized to view this page. <a href="/" className="text-[#6d9e37]">Click Here</a> to go to the homepage.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold">Billing Management</h1>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative inline-block">
|
||||
<Button onClick={() => setIsDropdownOpen(!isDropdownOpen)} variant={isDropdownOpen ? 'default' : 'outline'}>Manage Users</Button>
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute mt-1 w-44 bg-white border rounded-md shadow-lg z-10">
|
||||
<a href="/manager/customer-lists" className="block px-4 py-2 hover:bg-gray-100 rounded-md text-sm text-[#6d9e37] font-medium">Manage Customer</a>
|
||||
<a href="/manager/selling-list" className="block px-4 py-2 hover:bg-gray-100 rounded-md text-sm text-[#6d9e37] font-medium">Manage Billing</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleCreateNew} variant="outline">Create New</Button>
|
||||
<Button onClick={exportToExcel} variant="outline" className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" /> Export to Excel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Section */}
|
||||
<div className="mb-2 text-sm text-gray-600">
|
||||
Showing {currentItems.length} of {filteredAndSortedData.length} results
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-t-lg shadow">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
className="bg-[#262626] pl-10 w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
name="status"
|
||||
value={filters.status}
|
||||
onChange={handleFilterChange}
|
||||
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
{uniqueStatuses.map(status => (
|
||||
<option key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
name="cycle"
|
||||
value={filters.cycle}
|
||||
onChange={handleFilterChange}
|
||||
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
|
||||
>
|
||||
<option value="">All cycles</option>
|
||||
{uniquecycles.map(cycle => (
|
||||
<option key={cycle} value={cycle}>
|
||||
{cycle.charAt(0).toUpperCase() + cycle.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
name="service"
|
||||
value={filters.service}
|
||||
onChange={handleFilterChange}
|
||||
className="bg-[#262626] px-3 py-2 border border-[#6d9e37] rounded-md outline-none"
|
||||
>
|
||||
<option value="">All Services</option>
|
||||
{uniqueServices.map(service => (
|
||||
<option key={service} value={service}>
|
||||
{service}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{(filters.status || filters.cycle || filters.service || searchTerm) && (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
onClick={resetFilters}
|
||||
size="sm"
|
||||
>
|
||||
Reset Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white rounded-b-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('billing_id')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Billing ID
|
||||
{sortConfig.key === 'billing_id' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('service')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Service
|
||||
{sortConfig.key === 'service' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('name')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
{sortConfig.key === 'name' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('user')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
User
|
||||
{sortConfig.key === 'user' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('amount')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Amount
|
||||
{sortConfig.key === 'amount' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('status')}>
|
||||
<div className="flex items-center">
|
||||
Status
|
||||
{sortConfig.key === 'status' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer"
|
||||
onClick={() => requestSort('service_status')}>
|
||||
<div className="flex items-center">
|
||||
Ser.. Status
|
||||
{sortConfig.key === 'service_status' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer" onClick={() => requestSort('created_at')}>
|
||||
<div className="flex items-center">
|
||||
Created At
|
||||
{sortConfig.key === 'created_at' && (
|
||||
sortConfig.direction === 'asc' ?
|
||||
<ChevronUp className="ml-1 h-4 w-4" /> :
|
||||
<ChevronDown className="ml-1 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{currentItems.length > 0 ? (
|
||||
currentItems.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{item.billing_id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{item.service}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{item.name ?? item.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{item.user}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-medium">{formatCurrency(item.amount)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(item.status)}`}>
|
||||
{item.status.charAt(0).toUpperCase() + item.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${item.service_status == 1 ? 'bg-green-100 text-green-800' : item.service_status == 0 ? 'bg-red-100 text-red-800' : ''}`}>
|
||||
{item.service_status == 1 ? 'Active' : item.service_status == 0 ? 'Deactivate' : ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(item.created_at)}
|
||||
</td>
|
||||
<td className="inline-flex px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
title="View Items"
|
||||
onClick={() => handleViewItem(item)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-2"
|
||||
>
|
||||
<Eye className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
title="Edit Items"
|
||||
onClick={() => handleEditItem(item)}
|
||||
className="text-yellow-600 hover:text-yellow-900 mr-2"
|
||||
>
|
||||
<Pencil className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
title="Delete Items"
|
||||
onClick={() => handleDeleteItem(item.billing_id, item.service_type)}
|
||||
className="text-red-600 hover:text-red-900 mr-2"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
<PDFDownloadLink
|
||||
title="Download PDF"
|
||||
document={<InvoicePDF data={item} />}
|
||||
fileName={`invoice_${item.billing_id}.pdf`}
|
||||
className="text-[#6d9e37] hover:text-green-600"
|
||||
>
|
||||
{({ loading }) => (loading ? '...' : <Download className="w-5 h-5" />)}
|
||||
</PDFDownloadLink>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="7" className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
No records found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{filteredAndSortedData.length > itemsPerPage && (
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{
|
||||
currentPage > 1 ? (
|
||||
<>
|
||||
<Button onClick={() => paginate(1)} disabled={currentPage === 1} variant="outline" size="sm">First</Button>
|
||||
<Button onClick={() => paginate(currentPage - 1)} disabled={currentPage === 1} variant="outline" size="sm">Previous</Button >
|
||||
</>
|
||||
) : ''
|
||||
}
|
||||
{
|
||||
getPaginationRange().map((item, index) => {
|
||||
if (item === '...') {
|
||||
return (
|
||||
<span key={`ellipsis-${index}`} className="px-2 py-1">...</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button key={`page-${item}`} onClick={() => paginate(item)} variant={currentPage === item ? "default" : "outline"} size="sm">{item}</Button>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<Button onClick={() => paginate(currentPage + 1)} disabled={currentPage === totalPages} variant="outline" size="sm">Next</Button>
|
||||
<Button onClick={() => paginate(totalPages)} disabled={currentPage === totalPages} variant="outline" size="sm">Last</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialog for View/Edit/Create */}
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCurrentStep(1);
|
||||
setSelectedCustomer(null);
|
||||
}
|
||||
setDialogOpen(open);
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editMode ? (selectedItem ? 'Edit Billing' : 'Create New Billing') : 'Billing Details'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{editMode && !selectedItem && currentStep === 1 ? (
|
||||
<div>
|
||||
<div className="relative mb-4">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search customers..."
|
||||
value={customerSearchTerm}
|
||||
onChange={(e) => setCustomerSearchTerm(e.target.value)}
|
||||
className="pl-10 w-full px-3 py-2 border border-gray-300 rounded-md outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold mb-4 text-neutral-400">Select Customer</h3>
|
||||
{filteredCustomers.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{filteredCustomers.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
onClick={() => handleCustomerSelect(user)}
|
||||
className="group p-3 mx-2 border rounded-md cursor-pointer hover:bg-gray-100 hover:border-[#6d9e37] transition-border duration-700"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-neutral-950">{user.name}</p>
|
||||
<p className="text-sm text-neutral-400">{user.email}</p>
|
||||
</div>
|
||||
<div className="text-sm inline-flex gap-2">
|
||||
<span className="bg-gray-100 px-2 py-1 rounded text-neutral-950">SiliconId: {user.siliconId}</span>
|
||||
<ArrowRight className="text-[#6d9e37] opacity-0 group-hover:opacity-100 transition-opacity duration-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-gray-500 py-4">No customers found</p>
|
||||
)}
|
||||
</div>
|
||||
) : editMode ? (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{!selectedItem && selectedCustomer && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-md">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="font-semibold text-neutral-950">Customer Information</h3>
|
||||
<p className="text-sm text-neutral-400"><span className="font-medium">Name:</span> {selectedCustomer.name}</p>
|
||||
<p className="text-sm text-neutral-400"><span className="font-medium">Email:</span> {selectedCustomer.email}</p>
|
||||
<p className="text-sm text-neutral-400"><span className="font-medium">Silicon ID:</span> {selectedCustomer.siliconId}</p>
|
||||
</div>
|
||||
{!selectedItem && (
|
||||
<Button type="button" onClick={handleBackToCustomerSelect} variant="ghost" size="sm" className="text-gray-500">
|
||||
<ArrowLeft className="w-4 h-4 mr-1" /> Change Customer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Service</label>
|
||||
<input type="text" name="service" value={formData.service} onChange={handleInputChange} className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none text-neutral-950" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">cycle</label>
|
||||
<select name="cycle" value={formData.cycle} onChange={handleInputChange} className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none text-neutral-950 bg-white" >
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
<option value="one-time">One-time</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Amount</label>
|
||||
<input type="number" name="amount" value={formData.amount} onChange={handleInputChange} className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none text-neutral-950 bg-white" step="0.01" min="0" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||
<select name="status" value={formData.status} onChange={handleInputChange} className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none text-neutral-950 bg-white" >
|
||||
<option value="pending">Pending</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="">
|
||||
<label htmlFor="billing_date" className="block text-sm font-medium text-gray-700 mb-1">Billing Date</label>
|
||||
<input type="date" name="billing_date" value={formData.billing_date} onChange={handleInputChange} className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none text-neutral-950" />
|
||||
</div>
|
||||
<div className="">
|
||||
<label htmlFor="remarks" className="block text-sm font-medium text-gray-700 mb-1">Remarks</label>
|
||||
<textarea name="remarks" value={formData.remarks} onChange={handleInputChange} className="w-full px-3 py-2 border border-[#6d9e37] rounded-md outline-none text-neutral-950"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="" >{selectedItem ? 'Update' : 'Create'}</Button>
|
||||
<Button type="button" onClick={() => { setDialogOpen(false); setCurrentStep(1);}} variant="outline">Cancel</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700">Billing ID</h3>
|
||||
<p>{selectedItem?.billing_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700">Service</h3>
|
||||
<p>{selectedItem?.service}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700">User</h3>
|
||||
<p>{selectedItem?.user}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700">Silicon ID</h3>
|
||||
<p>{selectedItem?.siliconId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700">cycle</h3>
|
||||
<p>{selectedItem?.cycle}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700">Amount</h3>
|
||||
<p>{formatCurrency(selectedItem?.amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700">Status</h3>
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(selectedItem?.status)}`}>
|
||||
{selectedItem?.status.charAt(0).toUpperCase() + selectedItem?.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700">Created At</h3>
|
||||
<p>{formatDate(selectedItem?.created_at)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700">Updated At</h3>
|
||||
<p>{formatDate(selectedItem?.updated_at)}</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<h3 className="font-semibold text-gray-700">Remarks</h3>
|
||||
<p>{selectedItem?.remarks}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<PDFDownloadLink
|
||||
document={<InvoicePDF data={selectedItem} />}
|
||||
fileName={`invoice_${selectedItem?.billing_id}.pdf`}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
{({ loading }) => (loading ? 'Preparing PDF...' : 'Download PDF')}
|
||||
</PDFDownloadLink>
|
||||
<Button
|
||||
onClick={() => handleEditItem(selectedItem)}
|
||||
className="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setDialogOpen(false)}
|
||||
variant="outline"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
214
src/components/Manager/HetznerList.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useToast } from "../ui/toast";
|
||||
import Loader from "../ui/loader";
|
||||
import { Button } from "../ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
|
||||
import Table from "../ui/table"; // Import your custom Table component
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
|
||||
export default function HetznerServicesList() {
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
const { showToast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [servers, setServers] = useState([]);
|
||||
const PUBLIC_HETZNER_API_KEY = import.meta.env.PUBLIC_HETZNER_API_KEY;
|
||||
|
||||
// Define fetchServers outside useEffect so it can be reused
|
||||
const fetchServers = async () => {
|
||||
try {
|
||||
const response = await fetch("https://api.hetzner.cloud/v1/servers", {
|
||||
headers: { "Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}` }
|
||||
});
|
||||
const data = await response.json();
|
||||
setServers(data.servers || []);
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to load servers",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, []);
|
||||
|
||||
const handlePowerAction = async (serverId, action) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}/actions/${action}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.action) {
|
||||
showToast({
|
||||
title: "Success",
|
||||
description: `Server ${action} action initiated`,
|
||||
variant: "success"
|
||||
});
|
||||
// Refresh server list after a short delay
|
||||
setTimeout(() => {
|
||||
fetchServers();
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: `Failed to ${action} server`,
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteServer = async (serverId) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${PUBLIC_HETZNER_API_KEY}`,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast({
|
||||
title: "Success",
|
||||
description: "Server deleted successfully",
|
||||
variant: "success"
|
||||
});
|
||||
// Remove the server from local state
|
||||
setServers(prev => prev.filter(server => server.id !== serverId));
|
||||
}
|
||||
} catch (error) {
|
||||
showToast({
|
||||
title: "Error",
|
||||
description: "Failed to delete server",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
let colorClass = "";
|
||||
switch (status) {
|
||||
case "running":
|
||||
colorClass = "bg-green-100 text-green-800";
|
||||
break;
|
||||
case "off":
|
||||
colorClass = "bg-gray-100 text-gray-800";
|
||||
break;
|
||||
case "starting":
|
||||
case "stopping":
|
||||
colorClass = "bg-yellow-100 text-yellow-800";
|
||||
break;
|
||||
default:
|
||||
colorClass = "bg-blue-100 text-blue-800";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Prepare data for your Table component
|
||||
const tableData = servers.map(server => ({
|
||||
name: server.name,
|
||||
status: getStatusBadge(server.status),
|
||||
type: server.server_type.name,
|
||||
location: server.datacenter.location.city,
|
||||
ip: server.public_net.ipv4 ? server.public_net.ipv4.ip : "None",
|
||||
backups: server.backup_window ? "Enabled" : "Disabled",
|
||||
actions: (
|
||||
<div className="flex gap-2">
|
||||
{server.status === "running" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePowerAction(server.id, "shutdown")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePowerAction(server.id, "reboot")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Reboot
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className=""
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePowerAction(server.id, "poweron")}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="bg-red-500 hover:bg-red-600 transition duration-500"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteServer(server.id)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
const tableHeaders = ["Name", "Status", "Type", "Location", "IPv4", "Backups", "Actions"];
|
||||
|
||||
|
||||
if (loading || (isLoggedIn && sessionData?.user_type === 'admin' && isLoading && servers.length === 0)) {
|
||||
return <Loader />;
|
||||
}
|
||||
if (!isLoggedIn || sessionData?.user_type !== 'admin') {
|
||||
return <p className="text-center mt-8">You are not authorized to view this page. <a href="/" className="text-[#6d9e37]">Click Here</a> to go to the homepage.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="container mx-auto my-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Your Hetzner Cloud Servers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{servers.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No servers found. Create your first server to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table
|
||||
headers={tableHeaders}
|
||||
data={tableData}
|
||||
striped
|
||||
hover
|
||||
caption="A list of your Hetzner Cloud servers"
|
||||
className="mt-4"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
0
src/components/Manager/VPNLists.jsx
Normal file
245
src/components/Manager/ViewAsUser.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, {useState, useEffect} from "react";
|
||||
import { useIsLoggedIn } from '../../lib/isLoggedIn';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
|
||||
import { Button } from "../ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { Eye, Download } from "lucide-react";
|
||||
import { PDFDownloadLink } from '@react-pdf/renderer';
|
||||
import InvoicePDF from "../../lib/InvoicePDF";
|
||||
import Loader from "../ui/loader";
|
||||
import { localizeTime } from "../../lib/localizeTime";
|
||||
|
||||
export default function ViewAsUser() {
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
const [userData, setUserData] = useState(null);
|
||||
const [billingData, setBillingData] = useState([]);
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
const [dataError, setDataError] = useState(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedInvoice, setSelectedInvoice] = useState(null);
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const id = urlParams.get('siliconId');
|
||||
if (id) {
|
||||
fetchUserData(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchUserData = async (id) => {
|
||||
setDataLoading(true);
|
||||
setDataError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${PUBLIC_USER_API_URL}?query=login-from-admin&siliconId=${id}`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setUserData(data.user || null);
|
||||
setBillingData(data.billing || []);
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to fetch data');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching data:', err);
|
||||
setDataError(err.message);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewInvoice = (invoice) => {
|
||||
setSelectedInvoice(invoice);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
if (loading || (isLoggedIn && sessionData?.user_type === 'admin' && dataLoading)) {
|
||||
return <Loader />;
|
||||
}
|
||||
if (error || dataError) return <p>Error: {error?.message || dataError.message}</p>;
|
||||
if (!isLoggedIn || sessionData?.user_type !== 'admin') {
|
||||
return <p className="text-center mt-8">You are not authorized to view this page. <a href="/" className="text-[#6d9e37]">Click Here</a> to go to the homepage.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 container mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold">View as {userData.name || 'User'}</h1>
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
||||
<div className="flex-1 lg:max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Information</CardTitle>
|
||||
<CardDescription>
|
||||
Details for user: {userData.siliconid}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
<AvatarImage src={userData.avatar || ''} />
|
||||
<AvatarFallback>
|
||||
{userData.name ? userData.name.charAt(0).toUpperCase() : 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{userData.name}</h3>
|
||||
<p className="text-sm text-gray-500">{userData.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Silicon ID</p>
|
||||
<p className="text-sm">{userData.siliconId}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">PB ID</p>
|
||||
<p className="text-sm">{userData.pbId}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Account Created</p>
|
||||
<p className="text-sm">
|
||||
{userData.created_at ? localizeTime(userData.created_at) : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">Last Updated</p>
|
||||
<p className="text-sm">
|
||||
{userData.updated_at ? localizeTime(userData.updated_at) : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Billing Information</CardTitle>
|
||||
<CardDescription>
|
||||
Billing history for this user
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{billingData.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Invoice ID</th>
|
||||
<th className="text-center">Date</th>
|
||||
<th className="text-left">Description</th>
|
||||
<th className="text-center">Amount</th>
|
||||
<th className="text-center">Status</th>
|
||||
<th className="text-center">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{billingData.map((invoice) => (
|
||||
<tr key={invoice.id} className="">
|
||||
<td>{invoice.billing_id}</td>
|
||||
<td className="text-center">
|
||||
{invoice?.created_at?.split(' ')[0] || 'N/A'}
|
||||
</td>
|
||||
<td className="line-clamp-1">{invoice.service}</td>
|
||||
<td className="text-center">${invoice.amount}</td>
|
||||
<td className={`text-center text-sm rounded-full h-fit ${
|
||||
invoice.status === 'pending' ? 'text-yellow-500' :
|
||||
invoice.status === 'completed' ? 'text-green-500' :
|
||||
'text-red-500'
|
||||
}`}>
|
||||
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
||||
</td>
|
||||
<td className="text-center flex justify-center items-center gap-2 p-2">
|
||||
<button
|
||||
onClick={() => handleViewInvoice(invoice)}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
<Eye className="w-5 h-5" />
|
||||
</button>
|
||||
<PDFDownloadLink
|
||||
document={<InvoicePDF data={invoice} />}
|
||||
fileName={`invoice_${invoice.billing_id}.pdf`}
|
||||
className="text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
{({ loading }) => (loading ? '...' : <Download className="w-5 h-5" />)}
|
||||
</PDFDownloadLink>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-gray-500 py-4">No billing records found</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invoice Details</DialogTitle>
|
||||
<DialogDescription>
|
||||
Details for Invoice ID: <span className="font-bold">{selectedInvoice?.billing_id}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selectedInvoice && (
|
||||
<div className="mt-4 space-y-4 text-sm text-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Service:</span>
|
||||
<span>{selectedInvoice.service}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Amount:</span>
|
||||
<span>${selectedInvoice.amount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Status:</span>
|
||||
<span className={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
selectedInvoice.status === 'pending' ? 'bg-yellow-500' :
|
||||
selectedInvoice.status === 'completed' ? 'bg-green-500' :
|
||||
'bg-red-500'
|
||||
}`}>
|
||||
{selectedInvoice.status}
|
||||
</span>
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Created At:</span>
|
||||
<span>{localizeTime(selectedInvoice.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Updated At:</span>
|
||||
<span>{selectedInvoice.updated_at ? localizeTime(selectedInvoice.updated_at) : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/components/MyTopic.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import TopicItems from "./TopicItem";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import Loader from "./ui/loader";
|
||||
const topicPageDesc = 'Cutting-edge discussions on tech, digital services, news, and digital freedom. Stay informed on AI, cybersecurity, privacy, and the future of innovation.';
|
||||
|
||||
export default function TopicCreation(props) {
|
||||
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||
const { isLoggedIn, loading: authLoading, error: authError } = useIsLoggedIn();
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTopics = async () => {
|
||||
try {
|
||||
const res = await fetch(`${PUBLIC_TOPIC_API_URL}?query=my-topics`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! status: ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setTopics(data.data || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTopics();
|
||||
}, []);
|
||||
|
||||
if (authLoading || loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (authError || error) {
|
||||
return <div className="error-message">Error loading data: {authError || error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoggedIn && (
|
||||
<div className="container mx-auto flex justify-end gap-x-4 mb-4">
|
||||
<a href="/topic/new" className="create-new-link">Create New</a>
|
||||
<a href="/topic/my-topic">My Topics</a>
|
||||
</div>
|
||||
)}
|
||||
<TopicItems topics={topics} mytopic={props.mytopic || false} title="SoliconPin Topics" description={topicPageDesc} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
485
src/components/NewTopic copy.jsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
|
||||
import { Button } from './ui/button';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { CustomTabs } from './ui/tabs';
|
||||
|
||||
const PUBLIC_MINIO_UPLOAD_URL = import.meta.env.PUBLIC_MINIO_UPLOAD_URL;
|
||||
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||
|
||||
const NewTopic = () => {
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({ status: 'draft', category: '', title: '', content: '', imageUrl: '' });
|
||||
// UI state
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||
const [imageUrlInput, setImageUrlInput] = useState('');
|
||||
const [imageUploadFile, setImageUploadFile] = useState(null);
|
||||
const [imageUploadPreview, setImageUploadPreview] = useState('');
|
||||
const [editorMode, setEditorMode] = useState('edit');
|
||||
|
||||
// Upload file to MinIO
|
||||
const uploadToMinIO = async (file, onProgress) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('api_key', 'wweifwehfwfhwhtuyegbvijvbfvegfreyf');
|
||||
|
||||
try {
|
||||
const response = await fetch(PUBLIC_MINIO_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(data)
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// // Generate slug from title
|
||||
// useEffect(() => {
|
||||
// if (formData.title) {
|
||||
// const slug = formData.title
|
||||
// .toLowerCase()
|
||||
// .replace(/[^\w\s]/g, '')
|
||||
// .replace(/\s+/g, '-');
|
||||
// setFormData(prev => ({ ...prev, slug }));
|
||||
// }
|
||||
// }, [formData.title]);
|
||||
|
||||
const customImageCommand = {
|
||||
name: 'image',
|
||||
keyCommand: 'image',
|
||||
buttonProps: { 'aria-label': 'Insert image' },
|
||||
icon: (
|
||||
<svg width="12" height="12" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M15 9c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4-7H1c-.55 0-1 .45-1 1v14c0 .55.45 1 1 1h18c.55 0 1-.45 1-1V3c0-.55-.45-1-1-1zm-1 13l-6-5-2 2-4-5-4 8V4h16v11z"/>
|
||||
</svg>
|
||||
),
|
||||
execute: () => {
|
||||
setImageDialogOpen(true);
|
||||
},
|
||||
};
|
||||
|
||||
// Get all default commands and replace the image command
|
||||
const allCommands = commands.getCommands().map(cmd => {
|
||||
if (cmd.name === 'image') {
|
||||
return customImageCommand;
|
||||
}
|
||||
return cmd;
|
||||
});
|
||||
|
||||
|
||||
// Handle image URL insertion
|
||||
const handleInsertImageUrl = () => {
|
||||
if (imageUrlInput) {
|
||||
const imgMarkdown = ``;
|
||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||
if (textarea) {
|
||||
const startPos = textarea.selectionStart;
|
||||
const endPos = textarea.selectionEnd;
|
||||
const currentValue = formData.content;
|
||||
|
||||
const newValue =
|
||||
currentValue.substring(0, startPos) +
|
||||
imgMarkdown +
|
||||
currentValue.substring(endPos);
|
||||
|
||||
setFormData(prev => ({ ...prev, content: newValue }));
|
||||
}
|
||||
setImageDialogOpen(false);
|
||||
setImageUrlInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle image file selection
|
||||
const handleImageFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setImageUploadFile(file);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImageUploadPreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload image file to MinIO and insert into editor
|
||||
const handleImageUpload = async () => {
|
||||
if (!imageUploadFile) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
const uploadedUrl = await uploadToMinIO(imageUploadFile, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
});
|
||||
|
||||
// Insert markdown for the uploaded image
|
||||
const imgMarkdown = ``;
|
||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||
if (textarea) {
|
||||
const startPos = textarea.selectionStart;
|
||||
const endPos = textarea.selectionEnd;
|
||||
const currentValue = formData.content;
|
||||
|
||||
const newValue =
|
||||
currentValue.substring(0, startPos) +
|
||||
imgMarkdown +
|
||||
currentValue.substring(endPos);
|
||||
|
||||
setFormData(prev => ({ ...prev, content: newValue }));
|
||||
}
|
||||
|
||||
setImageDialogOpen(false);
|
||||
setImageUploadFile(null);
|
||||
setImageUploadPreview('');
|
||||
} catch (error) {
|
||||
setError('Failed to upload image: ' + error.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Form submission
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// Upload featured image if selected
|
||||
let imageUrl = formData.imageUrl;
|
||||
if (imageFile) {
|
||||
imageUrl = await uploadToMinIO(imageFile, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare payload
|
||||
const payload = {
|
||||
...formData,
|
||||
imageUrl
|
||||
};
|
||||
|
||||
// Submit to API
|
||||
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=create-new-topic`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to create topic');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form field changes
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handle featured image file selection
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setImageFile(file);
|
||||
// Preview image
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setFormData(prev => ({ ...prev, imageUrl: reader.result }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
status: 'draft',
|
||||
category: '',
|
||||
title: '',
|
||||
slug: '',
|
||||
content: '',
|
||||
imageUrl: ''
|
||||
});
|
||||
setImageFile(null);
|
||||
setSuccess(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// Success state
|
||||
if (success) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 text-center py-8">
|
||||
<h3 className="text-xl font-semibold text-green-600 mb-4">Topic created successfully!</h3>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button onClick={resetForm}>Create Another</Button>
|
||||
<Button variant="outline" onClick={() => window.location.href = '/'}>Return Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-3xl mx-auto bg-neutral-800 rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-[#6d9e37] mb-2">Create New Topic</h2>
|
||||
<p className="text-gray-600 mb-6">Start a new discussion in the SiliconPin community</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
name="status"
|
||||
required
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<SelectTrigger id="status" className="text-sm sm:text-base w-full">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="published">Published</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category *</Label>
|
||||
<Select
|
||||
name="category"
|
||||
required
|
||||
value={formData.category}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, category: value }))}
|
||||
>
|
||||
<SelectTrigger id="category" className="text-sm sm:text-base w-full">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="z-50">
|
||||
<SelectItem value="php">PHP Hosting</SelectItem>
|
||||
<SelectItem value="nodejs">Node.js Hosting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title and Slug */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input type="text" name="title" value={formData.title} onChange={handleChange} placeholder="Enter a descriptive title" required />
|
||||
</div>
|
||||
|
||||
{/* <div className="space-y-2">
|
||||
<Label htmlFor="slug">URL Slug</Label>
|
||||
<Input type="text" name="slug" value={formData.slug} onChange={handleChange} readOnly className="bg-gray-50" />
|
||||
<p className="text-xs text-gray-500">This will be used in the topic URL</p>
|
||||
</div> */}
|
||||
|
||||
{/* Content Editor with Preview Toggle */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="content">Content *</Label>
|
||||
<button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setEditorMode(editorMode === 'edit' ? 'preview' : 'edit')}
|
||||
className={`ml-2 ${editorMode !== 'edit' ? 'bg-[#6d9e37]' : ''} text-white border border-[#6d9e37] text-[#6d9e37] px-2 py-1 rounded-md`}
|
||||
>
|
||||
{editorMode === 'edit' ? 'Preview' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
placeholder="Write your content"
|
||||
value={formData.content}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, content: value || '' }))}
|
||||
height={400}
|
||||
preview={editorMode}
|
||||
commands={allCommands}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload Dialog */}
|
||||
<Dialog open={imageDialogOpen} onOpenChange={setImageDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Image</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomTabs
|
||||
tabs={[
|
||||
{
|
||||
label: "From URL",
|
||||
value: "url",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter image URL"
|
||||
value={imageUrlInput}
|
||||
onChange={(e) => setImageUrlInput(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInsertImageUrl}
|
||||
disabled={!imageUrlInput}
|
||||
>
|
||||
Insert Image
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: "Upload",
|
||||
value: "upload",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileSelect}
|
||||
/>
|
||||
{imageUploadPreview && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={imageUploadPreview}
|
||||
alt="Preview"
|
||||
className="max-h-40 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImageUpload}
|
||||
disabled={!imageUploadFile || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Uploading...' : 'Upload & Insert'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Featured Image Upload */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image">Featured Image</Label>
|
||||
<Input
|
||||
type="file"
|
||||
id="image"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.imageUrl && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={formData.imageUrl}
|
||||
alt="Preview"
|
||||
className="max-h-40 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetForm}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="min-w-32"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="animate-spin">↻</span>
|
||||
Creating...
|
||||
</span>
|
||||
) : 'Create Topic'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-600 rounded-md">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewTopic;
|
||||
530
src/components/NewTopic.jsx
Normal file
@@ -0,0 +1,530 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
|
||||
import { Button } from './ui/button';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { CustomTabs } from './ui/tabs';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
const PUBLIC_MINIO_UPLOAD_URL = 'https://file-vault-prod-pdgctwgsnkiy.siliconpin.com/upload';
|
||||
const IMAGE_URL_PREFIX = 'https://file-delivery.ndgctwgsnkiy.siliconpin.com/';
|
||||
// const PUBLIC_MINIO_UPLOAD_URL = import.meta.env.PUBLIC_MINIO_UPLOAD_URL;
|
||||
|
||||
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||
|
||||
const NewTopic = () => {
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({ status: 'draft', category: '', title: '', content: '', imageUrl: '' });
|
||||
// UI state
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||
const [imageUrlInput, setImageUrlInput] = useState('');
|
||||
const [imageUploadFile, setImageUploadFile] = useState(null);
|
||||
const [imageUploadPreview, setImageUploadPreview] = useState('');
|
||||
const [editorMode, setEditorMode] = useState('edit');
|
||||
|
||||
const uploadToMinIO = async (file, onProgress) => {
|
||||
const siliconId = Cookies.get('siliconId'); // Get from cookie or anywhere
|
||||
const token = Cookies.get('token'); // Get from cookie or storage
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(PUBLIC_MINIO_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-user-data': siliconId,
|
||||
'Authorization': token,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// console.log('Upload successful:', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// // Generate slug from title
|
||||
// useEffect(() => {
|
||||
// if (formData.title) {
|
||||
// const slug = formData.title
|
||||
// .toLowerCase()
|
||||
// .replace(/[^\w\s]/g, '')
|
||||
// .replace(/\s+/g, '-');
|
||||
// setFormData(prev => ({ ...prev, slug }));
|
||||
// }
|
||||
// }, [formData.title]);
|
||||
|
||||
const customImageCommand = {
|
||||
name: 'image',
|
||||
keyCommand: 'image',
|
||||
buttonProps: { 'aria-label': 'Insert image' },
|
||||
icon: (
|
||||
<svg width="12" height="12" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M15 9c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4-7H1c-.55 0-1 .45-1 1v14c0 .55.45 1 1 1h18c.55 0 1-.45 1-1V3c0-.55-.45-1-1-1zm-1 13l-6-5-2 2-4-5-4 8V4h16v11z"/>
|
||||
</svg>
|
||||
),
|
||||
execute: () => {
|
||||
setImageDialogOpen(true);
|
||||
},
|
||||
};
|
||||
|
||||
// Custom <br> insert command
|
||||
const lineBreakCommand = {
|
||||
name: 'linebreak',
|
||||
keyCommand: 'linebreak',
|
||||
buttonProps: { 'aria-label': 'Insert line break' },
|
||||
icon: (
|
||||
<svg width="16px" height="16px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M20 7V8.2C20 9.88016 20 10.7202 19.673 11.362C19.3854 11.9265 18.9265 12.3854 18.362 12.673C17.7202 13 16.8802 13 15.2 13H4M4 13L8 9M4 13L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>
|
||||
),
|
||||
execute: () => {
|
||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
|
||||
|
||||
const value = formData.content || "";
|
||||
const breakTag = '<br>';
|
||||
|
||||
const newValue = value.slice(0, start) + breakTag + value.slice(end);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
content: newValue
|
||||
}));
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + breakTag.length;
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Get all commands (same as NewTopic)
|
||||
const allCommands = [
|
||||
...commands.getCommands().map(cmd => {
|
||||
if (cmd.name === 'image') {
|
||||
return customImageCommand;
|
||||
}
|
||||
return cmd;
|
||||
}),
|
||||
lineBreakCommand,
|
||||
];
|
||||
|
||||
|
||||
// Handle image URL insertion
|
||||
const handleInsertImageUrl = () => {
|
||||
if (imageUrlInput) {
|
||||
const imgMarkdown = ``;
|
||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||
if (textarea) {
|
||||
const startPos = textarea.selectionStart;
|
||||
const endPos = textarea.selectionEnd;
|
||||
const currentValue = formData.content;
|
||||
|
||||
const newValue =
|
||||
currentValue.substring(0, startPos) +
|
||||
imgMarkdown +
|
||||
currentValue.substring(endPos);
|
||||
|
||||
setFormData(prev => ({ ...prev, content: newValue }));
|
||||
}
|
||||
setImageDialogOpen(false);
|
||||
setImageUrlInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle image file selection
|
||||
const handleImageFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setImageUploadFile(file);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImageUploadPreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload image file to MinIO and insert into editor
|
||||
const handleImageUpload = async () => {
|
||||
if (!imageUploadFile) return;
|
||||
console.log(imageUploadFile)
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
const uploadedUrl = await uploadToMinIO(imageUploadFile, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
});
|
||||
|
||||
// Insert markdown for the uploaded image
|
||||
const imgMarkdown = ``;
|
||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||
if (textarea) {
|
||||
const startPos = textarea.selectionStart;
|
||||
const endPos = textarea.selectionEnd;
|
||||
const currentValue = formData.content;
|
||||
|
||||
const newValue =
|
||||
currentValue.substring(0, startPos) +
|
||||
imgMarkdown +
|
||||
currentValue.substring(endPos);
|
||||
|
||||
setFormData(prev => ({ ...prev, content: newValue }));
|
||||
}
|
||||
|
||||
setImageDialogOpen(false);
|
||||
setImageUploadFile(null);
|
||||
setImageUploadPreview('');
|
||||
} catch (error) {
|
||||
setError('Failed to upload image: ' + error.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Form submission
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// Upload featured image if selected
|
||||
let imageUrl = formData.imageUrl.url;
|
||||
if (imageFile) {
|
||||
imageUrl = await uploadToMinIO(imageFile, (progress) => {
|
||||
console.log('imageUrl', imageUrl)
|
||||
setUploadProgress(progress);
|
||||
});
|
||||
}
|
||||
imageUrl = imageUrl.url;
|
||||
// Prepare payload
|
||||
const payload = {
|
||||
...formData,
|
||||
imageUrl
|
||||
};
|
||||
|
||||
// Submit to API
|
||||
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=create-new-topic`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to create topic');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form field changes
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handle featured image file selection
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setImageFile(file);
|
||||
// Preview image
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setFormData(prev => ({ ...prev, imageUrl: reader.result }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
status: 'draft',
|
||||
category: '',
|
||||
title: '',
|
||||
slug: '',
|
||||
content: '',
|
||||
imageUrl: ''
|
||||
});
|
||||
setImageFile(null);
|
||||
setSuccess(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// Success state
|
||||
if (success) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 text-center py-8">
|
||||
<h3 className="text-xl font-semibold text-green-600 mb-4">Topic created successfully!</h3>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button onClick={resetForm}>Create Another</Button>
|
||||
<Button variant="outline" onClick={() => window.location.href = '/'}>Return Home</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-3xl mx-auto bg-neutral-800 rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-[#6d9e37] mb-2">Create New Topic</h2>
|
||||
<p className="text-gray-600 mb-6">Start a new discussion in the SiliconPin community</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
name="status"
|
||||
required
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<SelectTrigger id="status" className="text-sm sm:text-base w-full">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="published">Published</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category *</Label>
|
||||
<Select
|
||||
name="category"
|
||||
required
|
||||
value={formData.category}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, category: value }))}
|
||||
>
|
||||
<SelectTrigger id="category" className="text-sm sm:text-base w-full">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="z-50">
|
||||
<SelectItem value="uncategorized">Uncategorized</SelectItem>
|
||||
<SelectItem value="php">PHP Hosting</SelectItem>
|
||||
<SelectItem value="nodejs">Node.js Hosting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title and Slug */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input type="text" name="title" value={formData.title} onChange={handleChange} placeholder="Enter a descriptive title" required />
|
||||
</div>
|
||||
|
||||
{/* Featured Image Upload */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image">Featured Image <span className="text-xs text-[#4B5563]">(Hero image)</span></Label>
|
||||
<Input
|
||||
type="file"
|
||||
id="image"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.imageUrl && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={formData.imageUrl}
|
||||
alt="Preview"
|
||||
className="max-h-40 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* <div className="space-y-2">
|
||||
<Label htmlFor="slug">URL Slug</Label>
|
||||
<Input type="text" name="slug" value={formData.slug} onChange={handleChange} readOnly className="bg-gray-50" />
|
||||
<p className="text-xs text-gray-500">This will be used in the topic URL</p>
|
||||
</div> */}
|
||||
|
||||
{/* Content Editor with Preview Toggle */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="content">Content *</Label>
|
||||
<button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setEditorMode(editorMode === 'edit' ? 'preview' : 'edit')}
|
||||
className={`ml-2 ${editorMode !== 'edit' ? 'bg-[#6d9e37]' : ''} text-white border border-[#6d9e37] text-[#6d9e37] px-2 py-1 rounded-md`}
|
||||
>
|
||||
{editorMode === 'edit' ? 'Preview' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
placeholder="Write your content"
|
||||
value={formData.content}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, content: value || '' }))}
|
||||
height={400}
|
||||
preview={editorMode}
|
||||
commands={allCommands}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload Dialog */}
|
||||
<Dialog open={imageDialogOpen} onOpenChange={setImageDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Image</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomTabs
|
||||
tabs={[
|
||||
{
|
||||
label: "From URL",
|
||||
value: "url",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Enter image URL"
|
||||
value={imageUrlInput}
|
||||
onChange={(e) => setImageUrlInput(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInsertImageUrl}
|
||||
disabled={!imageUrlInput}
|
||||
>
|
||||
Insert Image
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: "Upload",
|
||||
value: "upload",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileSelect}
|
||||
/>
|
||||
{imageUploadPreview && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={imageUploadPreview}
|
||||
alt="Preview"
|
||||
className="max-h-40 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImageUpload}
|
||||
disabled={!imageUploadFile || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Uploading...' : 'Upload & Insert'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetForm}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="min-w-32"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="animate-spin">↻</span>
|
||||
Creating...
|
||||
</span>
|
||||
) : 'Create Topic'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-600 rounded-md">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewTopic;
|
||||
287
src/components/PasswordUpdateCard.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { Input } from "./ui/input";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { Label } from "./ui/label";
|
||||
// import type { RecordModel } from 'pocketbase';
|
||||
const PUBLIC_POCKETBASE_URL = import.meta.env.PUBLIC_POCKETBASE_URL;
|
||||
const pb = new PocketBase(PUBLIC_POCKETBASE_URL);
|
||||
|
||||
interface RecordModel {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
collectionName: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface UserRecord extends RecordModel {
|
||||
email: string;
|
||||
verified?: boolean;
|
||||
emailVisibility?: boolean;
|
||||
username?: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
provider?: string;
|
||||
providerData?: {
|
||||
[key: string]: {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
};
|
||||
lastPasswordReset?: string;
|
||||
passwordHash?: string;
|
||||
}
|
||||
|
||||
const PasswordUpdateCard = ({ userId, onLogout }: { userId: string, onLogout: () => void }) => {
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [status, setStatus] = useState({ message: '', isError: false });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOAuthUser, setIsOAuthUser] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkOAuthStatus = async () => {
|
||||
try {
|
||||
// Get user with all fields (including internal ones)
|
||||
const user = await pb.collection('users').getOne(userId, {
|
||||
fields: '*,provider,verified,passwordHash'
|
||||
});
|
||||
|
||||
// New reliable OAuth detection logic:
|
||||
const isOAuthUser = (
|
||||
// Case 1: No password but verified (typical OAuth pattern)
|
||||
(!user.passwordHash && user.verified) ||
|
||||
// Case 2: Has provider specified (older OAuth users)
|
||||
(user.provider && user.provider !== 'email') ||
|
||||
// Case 3: Check externalId field if exists (some OAuth implementations)
|
||||
(user.externalId && typeof user.externalId === 'string')
|
||||
);
|
||||
|
||||
console.log('OAuth determination:', {
|
||||
email: user.email,
|
||||
hasPassword: !!user.passwordHash,
|
||||
verified: user.verified,
|
||||
provider: user.provider,
|
||||
isOAuthUser
|
||||
});
|
||||
|
||||
setIsOAuthUser(isOAuthUser);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user data:", error);
|
||||
// Fallback to checking verification status
|
||||
setIsOAuthUser(pb.authStore.model?.verified === true);
|
||||
}
|
||||
};
|
||||
|
||||
checkOAuthStatus();
|
||||
}, [userId]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setStatus({ message: '', isError: false });
|
||||
|
||||
try {
|
||||
// Validate inputs
|
||||
if (newPassword !== confirmPassword) {
|
||||
throw new Error("New passwords don't match");
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
throw new Error("Password must be at least 8 characters");
|
||||
}
|
||||
|
||||
// SPECIAL HANDLING FOR OAUTH USERS
|
||||
if (isOAuthUser) {
|
||||
// For OAuth users, we need to use a different endpoint or method
|
||||
// since the regular update requires oldPassword
|
||||
const authData = await pb.collection('users').update(userId, {
|
||||
password: newPassword,
|
||||
passwordConfirm: confirmPassword
|
||||
}, {
|
||||
// This header might be needed depending on your PocketBase version
|
||||
headers: { 'X-Require-Password-Confirm': 'false' }
|
||||
});
|
||||
} else {
|
||||
// Regular password update flow
|
||||
await pb.collection('users').update(userId, {
|
||||
password: newPassword,
|
||||
passwordConfirm: confirmPassword,
|
||||
oldPassword: currentPassword
|
||||
});
|
||||
}
|
||||
|
||||
// Clear form
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
|
||||
setStatus({
|
||||
message: isOAuthUser
|
||||
? 'Password successfully set! You can now login with email'
|
||||
: 'Password updated successfully!',
|
||||
isError: false
|
||||
});
|
||||
|
||||
// Logout user after password change
|
||||
onLogout();
|
||||
} catch (error: any) {
|
||||
console.error("Password update failed:", error);
|
||||
|
||||
// Special handling for OAuth users if the first method fails
|
||||
if (isOAuthUser && error.data?.oldPassword?.message) {
|
||||
try {
|
||||
// Alternative method for OAuth users - create a new auth record
|
||||
await pb.collection('users').authWithPassword(
|
||||
pb.authStore.model?.email,
|
||||
newPassword
|
||||
);
|
||||
|
||||
setStatus({
|
||||
message: 'Password successfully set!',
|
||||
isError: false
|
||||
});
|
||||
onLogout();
|
||||
return;
|
||||
} catch (secondError) {
|
||||
error = secondError;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle specific PocketBase validation errors
|
||||
let errorMessage = error.message;
|
||||
if (error.data?.oldPassword?.message) {
|
||||
errorMessage = "Please provide your current password";
|
||||
} else if (error.data?.password?.message) {
|
||||
errorMessage = error.data.password.message;
|
||||
}
|
||||
|
||||
setStatus({
|
||||
message: errorMessage || "Failed to update password. Please try again.",
|
||||
isError: true
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security</CardTitle>
|
||||
<CardDescription>
|
||||
{isOAuthUser
|
||||
? "Set a password to enable email login"
|
||||
: "Update your password"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{status.message && (
|
||||
<div className={`p-3 rounded-md text-sm mb-4 ${status.isError ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOAuthUser && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">Current password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type={showCurrentPassword ? "text" : "password"}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required={!isOAuthUser}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showCurrentPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">
|
||||
{isOAuthUser ? "Set new password" : "New password"}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? "text" : "password"}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showNewPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Password must be at least 8 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">
|
||||
{isOAuthUser ? "Confirm new password" : "Confirm password"}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showConfirmPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4 w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{isOAuthUser ? "Setting password..." : "Updating..."}
|
||||
</>
|
||||
) : (
|
||||
isOAuthUser ? "Set Password" : "Update Password"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordUpdateCard;
|
||||
0
src/components/PaymentForm.jsx
Normal file
201
src/components/PaymentSuccessfully.jsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { FileDown } from "lucide-react";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import Loader from './ui/loader';
|
||||
export default function HestiaCredentialsFetcher() {
|
||||
const { isLoggedIn, loading, sessionData } = useIsLoggedIn();
|
||||
const [creds, setCreds] = useState(null);
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [serviceName, setServiceName] = useState(null);
|
||||
const [serviceOrderId, setServiceOrderId] = useState(null);
|
||||
const [userSiliconId, setUserSiliconId] = useState();
|
||||
const PUBLIC_SERVICE_API_URL = import.meta.env.PUBLIC_SERVICE_API_URL;
|
||||
const PUBLIC_HOST_API2_USERS = import.meta.env.PUBLIC_HOST_API2_USERS;
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const service = urlParams.get('service');
|
||||
const orderId = urlParams.get('orderId');
|
||||
setUserSiliconId(sessionData.siliconId);
|
||||
// console.log(sessionData.siliconId)
|
||||
setServiceName(service);
|
||||
setServiceOrderId(orderId);
|
||||
if (service && orderId ) {
|
||||
await fetchCredentials(service, orderId); // Pass orderId directly
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [sessionData]);
|
||||
|
||||
const handleSaveDataInMongoDB = async (servicesData, currentBillingId, serviceToEndpoint, siliconId) => {
|
||||
const query = serviceToEndpoint === 'vpn' ? 'vpns' : serviceToEndpoint === 'hosting' ? 'hostings' : '';
|
||||
try {
|
||||
const serviceDataPayload = {
|
||||
success: servicesData.success,
|
||||
service: servicesData.service,
|
||||
password: servicesData.password,
|
||||
output: servicesData.output,
|
||||
publicKey: servicesData.publicKey,
|
||||
endpoint: servicesData.endpoint,
|
||||
qr_code: servicesData.qr_code,
|
||||
continent: servicesData.continent,
|
||||
billingId: currentBillingId,
|
||||
status: "active"
|
||||
}
|
||||
// console.log('serviceDataPayload', serviceDataPayload)
|
||||
const response = await fetch(`${PUBLIC_HOST_API2_USERS}/${siliconId}/${query}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(serviceDataPayload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Node Backend API', data);
|
||||
return data; // Return the data for further processing if needed
|
||||
} catch (error) {
|
||||
console.error('An error occurred while saving to MongoDB', error);
|
||||
throw error; // Re-throw to handle in the calling function
|
||||
}
|
||||
};
|
||||
// Update fetchCredentials to accept orderId as parameter
|
||||
const fetchCredentials = async (serviceToCall, orderIdParam = null) => {
|
||||
setDataLoading(true);
|
||||
setError('');
|
||||
setCreds(null);
|
||||
|
||||
try {
|
||||
// Use the passed orderIdParam or fall back to state
|
||||
const effectiveOrderId = orderIdParam || serviceOrderId;
|
||||
const query = serviceToCall === 'vpn' ? 'get-vpn-cred' : 'get-hestia-cred';
|
||||
const response = await fetch(`${PUBLIC_SERVICE_API_URL}?query=${query}&orderId=${effectiveOrderId}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(errorData?.error || `HTTP error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (sessionData?.siliconId) {
|
||||
const mongoResponse = await handleSaveDataInMongoDB(data, effectiveOrderId, serviceToCall, sessionData.siliconId);
|
||||
console.log('mongoResponse', mongoResponse)
|
||||
}
|
||||
setCreds(data);
|
||||
} catch (err) {
|
||||
console.error("FETCH ERROR:", err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setDataLoading(false);
|
||||
}
|
||||
};
|
||||
const downloadConfig = () => {
|
||||
if (!creds?.output) return;
|
||||
|
||||
const element = document.createElement('a');
|
||||
const file = new Blob([creds.output], { type: 'application/octet-stream' });
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `${creds.username || 'vpn-config'}.conf`;
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
};
|
||||
console.log('serviceName', serviceName)
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
// Then handle not logged in state
|
||||
if (!isLoggedIn) {
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{serviceName === 'vpn' ? (
|
||||
<div className="p-4 bg-gray-100 rounded shadow max-w-lg mx-auto mt-10">
|
||||
{
|
||||
!creds ? (
|
||||
<>
|
||||
<h2 className="text-xl font-bold mb-4">Get VPN Credentials</h2>
|
||||
{!dataLoading && (
|
||||
<Button onClick={() => fetchCredentials('vpn')} disabled={dataLoading} className="w-full">
|
||||
{dataLoading ? 'Fetching...' : 'Get Credentials'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-bold mb-2 text-neutral-950">VPN Credentials</h2>
|
||||
<p className="text-neutral-950 mb-4">Install WireGuard (Available on Android / IOS / Linux / Windows / FreeBSD) Scan this QR code using WireGuard app or import the config file.</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
{dataLoading && <p>Loading credentials...</p>}
|
||||
|
||||
{creds && creds.service === 'vpn' && (
|
||||
<div className="mt-4 bg-white p-4 rounded border space-y-4">
|
||||
{creds.qr_code && (
|
||||
<div className="flex flex-col items-center">
|
||||
<img src={`data:image/png;base64,${creds.qr_code}`} alt="VPN Configuration QR Code" className="w-48 h-48 border border-gray-300 rounded" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creds.output && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="font-semibold text-neutral-950">Configuration File</h3>
|
||||
<Button onClick={downloadConfig} size="sm" variant='outline'>
|
||||
Download .conf <FileDown size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap bg-gray-50 p-3 rounded text-sm overflow-x-auto text-neutral-950">{creds.output}</pre>
|
||||
<p className="text-yellow-600 bg-yellow-50 p-3 rounded-md my-4 text-sm">⚠️ Make sure to save these credentials in a safe place. We do not store them.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-600 mt-2">{error}</p>}
|
||||
</div>
|
||||
) : creds && creds.service === 'hestia' ? (
|
||||
<div className="p-4 bg-gray-100 rounded shadow max-w-md mx-auto mt-10">
|
||||
<h2 className="text-xl font-bold mb-4">Hestia Credentials</h2>
|
||||
|
||||
{dataLoading && <p>Loading credentials...</p>}
|
||||
|
||||
{creds && creds.service === 'hestia' && (
|
||||
<div className="mt-4 bg-white p-4 rounded border">
|
||||
<p><strong>Username:</strong> {creds.username}</p>
|
||||
<p><strong>Password:</strong> {creds.password}</p>
|
||||
{creds.output && (
|
||||
<p><strong>Output:</strong> <pre className="whitespace-pre-wrap">{creds.output}</pre></p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-600 mt-2">{error}</p>}
|
||||
</div>
|
||||
) : serviceName === 'streaming_api' ? (
|
||||
<Card className='max-w-2xl mx-auto px-4 mt-8'>
|
||||
<CardContent>
|
||||
<CardHeader>
|
||||
<CardTitle>STT Streaming API Purchase Succesfully</CardTitle>
|
||||
<CardDescription>You can See Info on my Billing Section in Profile</CardDescription>
|
||||
</CardHeader>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : ''}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,99 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
|
||||
export interface ServiceCardProps {
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
features: string[];
|
||||
features: [];
|
||||
learnMoreUrl: string;
|
||||
buyButtonText: string;
|
||||
buyButtonUrl: string;
|
||||
}
|
||||
|
||||
export function ServiceCard({ title, description, imageUrl, features, learnMoreUrl }: ServiceCardProps) {
|
||||
export function ServiceCard({ title, description, imageUrl, features, learnMoreUrl, buyButtonText, buyButtonUrl }: ServiceCardProps) {
|
||||
// console.log('checkType', typeof features)
|
||||
// console.log('checkType', features)
|
||||
const { isLoggedIn, loading, sessionData } = useIsLoggedIn();
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const parsedFeatures = typeof features === 'string' ? JSON.parse(features) : features;
|
||||
|
||||
const handleBuyClick = () => {
|
||||
if (!isLoggedIn) {
|
||||
setShowLoginModal(true);
|
||||
return;
|
||||
}
|
||||
window.location.href = buyButtonUrl;
|
||||
console.log(buyButtonUrl)
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden transition-all hover:shadow-lg dark:border-neutral-700 h-full flex flex-col">
|
||||
<div className="relative h-48 w-full overflow-hidden bg-neutral-100 dark:bg-neutral-800">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`${title} illustration`}
|
||||
className="object-cover w-full h-full transition-transform duration-300 hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-xl text-[#6d9e37]">{title}</CardTitle>
|
||||
<CardDescription className="text-neutral-400">{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 mr-2 text-[#6d9e37] shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className='text-zinc-600'>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<a
|
||||
href={learnMoreUrl}
|
||||
className="inline-flex items-center justify-center rounded-md bg-[#6d9e37] px-4 py-2 text-sm font-medium text-white hover:bg-[#598035] transition-colors w-full"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<>
|
||||
<Card className="overflow-hidden transition-all hover:shadow-lg dark:border-neutral-700 h-full flex flex-col">
|
||||
<div className="relative h-48 w-full overflow-hidden bg-neutral-100 dark:bg-neutral-800">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`${title}`}
|
||||
className="object-cover w-full h-full transition-transform duration-300 hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-xl text-[#6d9e37]">{title}</CardTitle>
|
||||
<CardDescription className="text-neutral-400 text-justify">{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-grow">
|
||||
<ul className="space-y-2 text-sm">
|
||||
{
|
||||
parsedFeatures && (
|
||||
parsedFeatures.map((feature : string, index: number) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[#6d9e37] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
<span className='text-zinc-600'>{feature}</span>
|
||||
</li>
|
||||
))
|
||||
)
|
||||
}
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div className='flex gap-4 w-full'>
|
||||
{buyButtonText && buyButtonUrl && (
|
||||
<Button className='w-full' variant='outline' onClick={handleBuyClick}>{buyButtonText}</Button>
|
||||
)}
|
||||
<a
|
||||
href={learnMoreUrl}
|
||||
className="inline-flex items-center justify-center rounded-md bg-[#6d9e37] px-4 py-2 text-sm font-medium text-white hover:bg-[#598035] transition-colors w-full"
|
||||
>
|
||||
Details
|
||||
</a>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Login Required Modal */}
|
||||
<Dialog open={showLoginModal} onOpenChange={setShowLoginModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl mb-2">Login Required</DialogTitle>
|
||||
<DialogDescription>
|
||||
You need to be logged in to purchase this service.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 flex flex-col space-y-2">
|
||||
<Button onClick={() => window.location.href = '/login'} className="w-full bg-[#6d9e37] hover:bg-[#598035]">Login Now</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => setShowLoginModal(false)}
|
||||
>
|
||||
Continue Browsing
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
337
src/components/SignupPage.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { Input } from "./ui/input";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { Label } from "./ui/label";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import Loader from "./ui/loader";
|
||||
|
||||
interface AuthStatus {
|
||||
message: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
interface UserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
avatar?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
token: string;
|
||||
record: UserRecord;
|
||||
}
|
||||
|
||||
const SignupPage = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordVisible, setPasswordVisible] = useState(false);
|
||||
const [confirmPasswordVisible, setConfirmPasswordVisible] = useState(false);
|
||||
const [status, setStatus] = useState<AuthStatus>({ message: '', isError: false });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
const PUBLIC_POCKETBASE_URL = import.meta.env.PUBLIC_POCKETBASE_URL;
|
||||
|
||||
const pb = new PocketBase(PUBLIC_POCKETBASE_URL);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setStatus({ message: '', isError: false });
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setStatus({ message: 'Passwords do not match', isError: true });
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create the user
|
||||
const userData = {
|
||||
email,
|
||||
name,
|
||||
password,
|
||||
passwordConfirm: confirmPassword,
|
||||
emailVisibility: true,
|
||||
};
|
||||
|
||||
const record = await pb.collection('users').create(userData);
|
||||
|
||||
// Automatically login after signup
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
if (!authData?.token || !authData?.record) {
|
||||
throw new Error("Authentication failed after signup");
|
||||
}
|
||||
|
||||
const avatarUrl = authData.record.avatar ? pb.files.getUrl(authData.record, authData.record.avatar) : '';
|
||||
|
||||
const authResponse: AuthResponse = {
|
||||
token: authData.token,
|
||||
record: {
|
||||
id: authData.record.id,
|
||||
email: authData.record.email,
|
||||
name: authData.record.name || authData.record.email.split('@')[0],
|
||||
type: authData.record.type || 'user',
|
||||
avatar: authData.record.avatar || '',
|
||||
}
|
||||
};
|
||||
|
||||
await syncSessionWithBackend(authResponse, avatarUrl);
|
||||
window.location.href = '/profile';
|
||||
} catch (error: any) {
|
||||
console.error("Signup failed:", error);
|
||||
setStatus({
|
||||
message: error.message || "Signup failed. Please try again.",
|
||||
isError: true
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signupWithOAuth2 = async (provider: 'google' | 'facebook' | 'github') => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus({ message: '', isError: false });
|
||||
|
||||
const authData = await pb.collection('users').authWithOAuth2({ provider });
|
||||
|
||||
if (!authData?.record) {
|
||||
throw new Error("No user record found");
|
||||
}
|
||||
|
||||
const avatarUrl = authData.record.avatar ? pb.files.getUrl(authData.record, authData.record.avatar) : '';
|
||||
const authResponse: AuthResponse = {
|
||||
token: authData.token,
|
||||
record: {
|
||||
id: authData.record.id,
|
||||
email: authData.record.email || '',
|
||||
name: authData.record.name || '',
|
||||
type: authData.record.type || '',
|
||||
avatar: authData.record.avatar || ''
|
||||
}
|
||||
};
|
||||
|
||||
await syncSessionWithBackend(authResponse, avatarUrl);
|
||||
window.location.href = '/profile';
|
||||
} catch (error) {
|
||||
console.error(`${provider} Signup failed:`, error);
|
||||
setStatus({
|
||||
message: `${provider} signup failed. Please try again.`,
|
||||
isError: true
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncSessionWithBackend = async (authData: AuthResponse, avatarUrl: string) => {
|
||||
try {
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=login`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: 'new',
|
||||
accessToken: authData.token,
|
||||
email: authData.record.email,
|
||||
name: authData.record.name,
|
||||
type: authData.record.type,
|
||||
avatar: avatarUrl,
|
||||
isAuthenticated: true,
|
||||
id: authData.record.id
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Session synced with backend:', data);
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
console.error('Error syncing session:', error);
|
||||
throw new Error(`Session sync failed: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const { isLoggedIn, loading, error } = useIsLoggedIn();
|
||||
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
window.location.href = '/';
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md shadow-lg rounded-xl overflow-hidden">
|
||||
<CardHeader className="text-center space-y-1">
|
||||
<CardTitle className="text-2xl font-bold">Create an Account</CardTitle>
|
||||
<CardDescription className="">
|
||||
Join us to get started
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{status.message && (
|
||||
<div className={`p-3 rounded-md text-sm ${status.isError ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
|
||||
{status.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Your Name"
|
||||
required
|
||||
className="focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
className="focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={passwordVisible ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
className="focus:ring-2 focus:ring-blue-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPasswordVisible(!passwordVisible)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{passwordVisible ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type={confirmPasswordVisible ? "text" : "password"}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
className="focus:ring-2 focus:ring-blue-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmPasswordVisible(!confirmPasswordVisible)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{confirmPasswordVisible ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
'Sign Up'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator className="w-full" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or sign up with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => signupWithOAuth2('google')}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<img src="/assets/google.svg" alt="Google" className="h-6 w-6" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => signupWithOAuth2('facebook')}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<img src="/assets/facebook.svg" alt="Facebook" className="h-6 w-6" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => signupWithOAuth2('github')}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2"
|
||||
>
|
||||
<img src="/assets/github.svg" alt="GitHub" className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<div className="px-6 pb-6 text-center text-sm text-gray-500">
|
||||
Already have an account?{' '}
|
||||
<a href="/login" className="font-medium text-[#6d9e37] hover:underline">
|
||||
Sign in
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupPage;
|
||||
171
src/components/TestCool.jsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
export default function DeployWordPress() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [debugInfo, setDebugInfo] = useState(null);
|
||||
|
||||
// Configuration - Replace with your actual values
|
||||
const COOLIFY_API_URL = "http://192.168.1.197:8000/api/v1";
|
||||
const TOKEN = "zXSR33z74eK26abbKyL9bz4d3PYouTSK8FSjOltv719c52d8";
|
||||
const PROJECT_UUID = "wc40gg048gkwg0go80ggog44";
|
||||
const SERVER_UUID = "sgswssowscc84o8sc8wockgc";
|
||||
const ENVIRONMENT_NAME = "production"; // Changed from 'dev' to 'production' as default
|
||||
|
||||
const createWordPress = async () => {
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
setDebugInfo(null);
|
||||
|
||||
try {
|
||||
// 1. Create MySQL Service - Updated to Coolify's expected format
|
||||
const mysqlPayload = {
|
||||
name: "wordpress-db",
|
||||
type: "mysql",
|
||||
projectUuid: PROJECT_UUID,
|
||||
serverUuid: SERVER_UUID,
|
||||
version: "8.0",
|
||||
destination: { // Coolify often requires this structure
|
||||
serverUuid: SERVER_UUID,
|
||||
environment: ENVIRONMENT_NAME
|
||||
},
|
||||
configuration: {
|
||||
type: "mysql",
|
||||
settings: { // Changed from environmentVariables to settings
|
||||
MYSQL_ROOT_PASSWORD: "example",
|
||||
MYSQL_DATABASE: "wordpress",
|
||||
MYSQL_USER: "wordpress",
|
||||
MYSQL_PASSWORD: "example",
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: "no"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mysqlRes = await fetch(`${COOLIFY_API_URL}/services`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(mysqlPayload),
|
||||
});
|
||||
|
||||
const mysqlData = await mysqlRes.json();
|
||||
setDebugInfo({ mysql: { request: mysqlPayload, response: mysqlData } });
|
||||
|
||||
if (!mysqlRes.ok) {
|
||||
throw new Error(
|
||||
mysqlData.message ||
|
||||
mysqlData.error?.message ||
|
||||
JSON.stringify(mysqlData.errors) ||
|
||||
"MySQL validation failed"
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Create WordPress Service
|
||||
const wpPayload = {
|
||||
name: "wordpress",
|
||||
type: "wordpress",
|
||||
projectUuid: PROJECT_UUID,
|
||||
serverUuid: SERVER_UUID,
|
||||
version: "latest",
|
||||
destination: {
|
||||
serverUuid: SERVER_UUID,
|
||||
environment: ENVIRONMENT_NAME
|
||||
},
|
||||
configuration: {
|
||||
type: "wordpress",
|
||||
settings: {
|
||||
WORDPRESS_DB_HOST: "wordpress-db",
|
||||
WORDPRESS_DB_USER: "wordpress",
|
||||
WORDPRESS_DB_PASSWORD: "example",
|
||||
WORDPRESS_DB_NAME: "wordpress",
|
||||
WORDPRESS_TABLE_PREFIX: "wp_"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const wpRes = await fetch(`${COOLIFY_API_URL}/services`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(wpPayload),
|
||||
});
|
||||
|
||||
const wpData = await wpRes.json();
|
||||
setDebugInfo(prev => ({ ...prev, wordpress: { request: wpPayload, response: wpData } }));
|
||||
|
||||
if (!wpRes.ok) {
|
||||
throw new Error(
|
||||
wpData.message ||
|
||||
wpData.error?.message ||
|
||||
JSON.stringify(wpData.errors) ||
|
||||
"WordPress validation failed"
|
||||
);
|
||||
}
|
||||
|
||||
setMessage("🎉 WordPress + MySQL deployed successfully!");
|
||||
} catch (err) {
|
||||
setMessage(`❌ Deployment failed: ${err.message}`);
|
||||
console.error("Deployment error:", err, debugInfo);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-md mx-auto bg-white rounded-xl shadow-md">
|
||||
<h2 className="text-2xl font-bold mb-4">Deploy WordPress</h2>
|
||||
|
||||
<button
|
||||
onClick={createWordPress}
|
||||
disabled={loading}
|
||||
className={`w-full py-2 px-4 rounded-md text-white font-medium ${
|
||||
loading ? "bg-gray-400 cursor-not-allowed" : "bg-blue-600 hover:bg-blue-700"
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Deploying...
|
||||
</span>
|
||||
) : "Deploy WordPress"}
|
||||
</button>
|
||||
|
||||
{message && (
|
||||
<div className={`mt-4 p-3 rounded-md ${
|
||||
message.includes("❌") ? "bg-red-50 text-red-700" : "bg-green-50 text-green-700"
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{debugInfo && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<details className="bg-gray-50 rounded-md p-2">
|
||||
<summary className="font-medium cursor-pointer">Debug Details</summary>
|
||||
<div className="mt-2 bg-white p-2 rounded border border-gray-200 overflow-auto max-h-60">
|
||||
<pre>{JSON.stringify(debugInfo, null, 2)}</pre>
|
||||
</div>
|
||||
</details>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p className="font-medium">Troubleshooting:</p>
|
||||
<ul className="list-disc pl-5 space-y-1 mt-1">
|
||||
<li>Verify <code>PROJECT_UUID</code> and <code>SERVER_UUID</code> are correct</li>
|
||||
<li>Check if environment <code>{ENVIRONMENT_NAME}</code> exists</li>
|
||||
<li>Ensure your Coolify version supports this API format</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// [{"uuid":"sgswssowscc84o8sc8wockgc","description":"This is the server where Coolify is running on. Don't delete this!","name":"localhost","ip":"host.docker.internal","is_coolify_host":true,"is_reachable":true,"is_usable":true,"port":22,"proxy":{"redirect_enabled":true},"settings":{"id":1,"concurrent_builds":2,"delete_unused_networks":false,"delete_unused_volumes":false,"docker_cleanup_frequency":"0 0 * * *","docker_cleanup_threshold":80,"dynamic_timeout":3600,"force_disabled":false,"force_docker_cleanup":true,"generate_exact_labels":false,"is_build_server":false,"is_cloudflare_tunnel":false,"is_jump_server":false,"is_logdrain_axiom_enabled":false,"is_logdrain_custom_enabled":false,"is_logdrain_highlight_enabled":false,"is_logdrain_newrelic_enabled":false,"is_metrics_enabled":false,"is_reachable":true,"is_sentinel_debug_enabled":false,"is_sentinel_enabled":false,"is_swarm_manager":false,"is_swarm_worker":false,"is_usable":true,"logdrain_axiom_api_key":null,"logdrain_axiom_dataset_name":null,"logdrain_custom_config":null,"logdrain_custom_config_parser":null,"logdrain_highlight_project_id":null,"logdrain_newrelic_base_uri":null,"logdrain_newrelic_license_key":null,"sentinel_custom_url":"http:\/\/host.docker.internal:8000","sentinel_metrics_history_days":7,"sentinel_metrics_refresh_rate_seconds":10,"sentinel_push_interval_seconds":60,"sentinel_token":"eyJpdiI6IllwMlBsOUtXODdUR0ZIbWtZenJRWFE9PSIsInZhbHVlIjoiZFUxcE9zSXFXdkVrN0tDUGdSbHpGVTE3cHVEeFlBM1hFWk56S05NVWVmVmxHV0tBQ2kra25uRnVzRzNHaFpBSGVTaGZLMGpqdS9nMU85MXhmS3VYMVE9PSIsIm1hYyI6ImY3ODQyNGI2OTVlMmRiMzFmNzJjZjRlYjNkNDIxOGVmZTAxOWMyNjY0ZTYxODE5MDIwY2FhMGUwYjU4ODMyN2MiLCJ0YWciOiIifQ==","server_disk_usage_check_frequency":"0 23 * * *","server_disk_usage_notification_threshold":80,"server_id":0,"server_timezone":"UTC","wildcard_domain":null,"created_at":"2025-04-03T17:12:45.000000Z","updated_at":"2025-04-03T17:16:25.000000Z"},"user":"root"}]
|
||||
144
src/components/TestFileUpload.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const ImageUploadTest = () => {
|
||||
const [file, setFile] = useState(null);
|
||||
const [preview, setPreview] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
|
||||
if (!selectedFile) return;
|
||||
|
||||
// Check if file is an image
|
||||
if (!selectedFile.type.match('image.*')) {
|
||||
setStatus('Error: Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setStatus('');
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setPreview(reader.result);
|
||||
reader.readAsDataURL(selectedFile);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!file) {
|
||||
setStatus('Error: No file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setStatus('Uploading...');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch('https://host-api.cs1.hz.siliconpin.com/v1/check/', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.text();
|
||||
setStatus(`Success: ${result}`);
|
||||
} catch (error) {
|
||||
setStatus(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '500px', margin: '20px auto', padding: '20px', border: '1px solid #ddd', borderRadius: '8px' }}>
|
||||
<h2>Image Upload Test</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} method='POST' action={'https://file-vault.ndgctwgsnkiy.siliconpin.com/upload'} encType="multipart/form-data">
|
||||
<div style={{ margin: '20px 0' }}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
id="file-upload"
|
||||
required
|
||||
/>
|
||||
<label htmlFor="file-upload" style={{
|
||||
display: 'inline-block',
|
||||
padding: '10px 15px',
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #ccc'
|
||||
}}>
|
||||
Choose Image
|
||||
</label>
|
||||
{file && (
|
||||
<span style={{ marginLeft: '10px' }}>{file.name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div style={{ margin: '20px 0' }}>
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
style={{ maxWidth: '100%', maxHeight: '200px', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!file || isLoading}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#4CAF50',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
opacity: (!file || isLoading) ? 0.6 : 1
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Uploading...' : 'Upload Image'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{status && (
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: status.startsWith('Error') ? '#ffebee' : '#e8f5e9',
|
||||
color: status.startsWith('Error') ? '#c62828' : '#2e7d32'
|
||||
}}>
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '20px', fontSize: '0.9em', color: '#666' }}>
|
||||
<p>Note: This component tests uploading to:</p>
|
||||
<code>https://file-vault.ndgctwgsnkiy.siliconpin.com/upload</code>
|
||||
<p>Make sure:</p>
|
||||
<ul>
|
||||
<li>Your Go server is running</li>
|
||||
<li>CORS is properly configured</li>
|
||||
<li>You have the <code>siliconId</code> cookie set</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploadTest;
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./ui/card";
|
||||
import Table from "./ui/table";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
@@ -28,7 +27,7 @@ interface Message {
|
||||
user_type: string;
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:2058/host-api/app/v1/ticket/index.php';
|
||||
const PUBLIC_TICKET_API_URL = import.meta.env.PUBLIC_TICKET_API_URL;
|
||||
|
||||
function Ticketing() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
@@ -48,7 +47,7 @@ function Ticketing() {
|
||||
|
||||
const fetchTickets = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}?action=get_tickets&status=${activeTab}`);
|
||||
const response = await fetch(`${PUBLIC_TICKET_API_URL}?action=get_tickets&status=${activeTab}`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setTickets(data.tickets);
|
||||
@@ -60,7 +59,7 @@ function Ticketing() {
|
||||
|
||||
const fetchMessages = async (ticketId: number) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}?action=get_messages&ticket_id=${ticketId}`);
|
||||
const response = await fetch(`${PUBLIC_TICKET_API_URL}?action=get_messages&ticket_id=${ticketId}`);
|
||||
const data = await response.json();
|
||||
console.log('Messages response:', data); // Debug log
|
||||
if (data.success) {
|
||||
@@ -79,7 +78,7 @@ function Ticketing() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const response = await fetch(API_URL, {
|
||||
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -100,7 +99,7 @@ function Ticketing() {
|
||||
|
||||
const handleStatusChange = async (ticketId: number, newStatus: 'open' | 'in-progress' | 'resolved' | 'closed') => {
|
||||
try {
|
||||
const response = await fetch(API_URL, {
|
||||
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -125,7 +124,7 @@ function Ticketing() {
|
||||
if (!selectedTicket || !newMessage.trim()) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(API_URL, {
|
||||
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -147,7 +146,7 @@ function Ticketing() {
|
||||
|
||||
const handleDeleteTicket = async (ticketId: number) => {
|
||||
try {
|
||||
const response = await fetch(API_URL, {
|
||||
const response = await fetch(PUBLIC_TICKET_API_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
773
src/components/Tools/AudioToText.jsx
Normal file
@@ -0,0 +1,773 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
const API_OPTIONS = [
|
||||
{
|
||||
id: 'whisper',
|
||||
name: 'Whisper (GPU)',
|
||||
endpoint: 'https://stt-41.siliconpin.com/stt',
|
||||
description: '2 Req / Min, 10 / Day is free'
|
||||
},
|
||||
{
|
||||
id: 'vosk',
|
||||
name: 'Vosk (CPU)',
|
||||
endpoint: 'https://api.vosk.ai/stt',
|
||||
description: '10 Req / Min, 100 / Day is free'
|
||||
},
|
||||
];
|
||||
|
||||
const MAX_FILE_SIZE_MB = 5;
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
|
||||
|
||||
export default function AudioUploader() {
|
||||
// State management
|
||||
const [file, setFile] = useState(null);
|
||||
const [status, setStatus] = useState('No file uploaded yet');
|
||||
const [response, setResponse] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedApi, setSelectedApi] = useState('whisper');
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [debugLogs, setDebugLogs] = useState([]);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [audioBlob, setAudioBlob] = useState(null);
|
||||
const [audioUrl, setAudioUrl] = useState(null);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
|
||||
// Refs
|
||||
const fileInputRef = useRef(null);
|
||||
const mediaRecorderRef = useRef(null);
|
||||
const audioChunksRef = useRef([]);
|
||||
const timerRef = useRef(null);
|
||||
const audioContextRef = useRef(null);
|
||||
const analyserRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const animationRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
|
||||
// Debug logging with useCallback to prevent infinite loops
|
||||
const addDebugLog = useCallback((message) => {
|
||||
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
||||
const logMessage = `${timestamp}: ${message}`;
|
||||
setDebugLogs(prev => [...prev.slice(-100), logMessage]);
|
||||
console.debug(logMessage);
|
||||
}, []);
|
||||
|
||||
// Timer effect
|
||||
useEffect(() => {
|
||||
if (isRecording) {
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingTime(prev => prev + 1);
|
||||
}, 1000);
|
||||
return () => clearInterval(timerRef.current);
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
addDebugLog('Component unmounting - cleaning up resources');
|
||||
stopRecording();
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
}
|
||||
if (audioContextRef.current?.state !== 'closed') {
|
||||
audioContextRef.current?.close().catch(err => {
|
||||
addDebugLog(`Error closing AudioContext: ${err.message}`);
|
||||
});
|
||||
}
|
||||
clearInterval(timerRef.current);
|
||||
};
|
||||
}, [addDebugLog]);
|
||||
|
||||
// Handle file change - completely stable implementation
|
||||
const handleFileChange = useCallback((e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (!selectedFile) {
|
||||
setFile(null);
|
||||
setStatus('No file selected');
|
||||
addDebugLog('No file selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/webm'].includes(selectedFile.type) &&
|
||||
!selectedFile.name.match(/\.(wav|mp3|ogg|webm)$/i)) {
|
||||
const errorMsg = 'Unsupported file format. Please use WAV, MP3, or OGG';
|
||||
setError(errorMsg);
|
||||
setStatus('Invalid file type');
|
||||
setFile(null);
|
||||
e.target.value = '';
|
||||
addDebugLog(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedFile.size > MAX_FILE_SIZE_BYTES) {
|
||||
const errorMsg = `File size exceeds ${MAX_FILE_SIZE_MB}MB limit`;
|
||||
setError(errorMsg);
|
||||
setStatus('File too large');
|
||||
setFile(null);
|
||||
e.target.value = '';
|
||||
addDebugLog(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
setFile(selectedFile);
|
||||
setStatus(`File selected: ${selectedFile.name}`);
|
||||
setResponse(null);
|
||||
setError(null);
|
||||
setAudioBlob(null);
|
||||
setAudioUrl(null);
|
||||
addDebugLog(`File selected: ${selectedFile.name} (${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB)`);
|
||||
}, [addDebugLog]);
|
||||
|
||||
// Create WAV blob - stable implementation
|
||||
const createWavBlob = useCallback(async (audioBlob) => {
|
||||
try {
|
||||
addDebugLog('Starting WAV blob creation');
|
||||
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: 16000
|
||||
});
|
||||
|
||||
const decodedData = await audioContext.decodeAudioData(arrayBuffer);
|
||||
addDebugLog(`Decoded audio data: ${decodedData.length} samples, ${decodedData.numberOfChannels} channels`);
|
||||
|
||||
let audioData;
|
||||
if (decodedData.numberOfChannels > 1) {
|
||||
audioData = new Float32Array(decodedData.length);
|
||||
for (let i = 0; i < decodedData.length; i++) {
|
||||
audioData[i] = (decodedData.getChannelData(0)[i] + decodedData.getChannelData(1)[i]) / 2;
|
||||
}
|
||||
addDebugLog('Converted stereo to mono');
|
||||
} else {
|
||||
audioData = decodedData.getChannelData(0);
|
||||
}
|
||||
|
||||
const pcmData = new Int16Array(audioData.length);
|
||||
for (let i = 0; i < audioData.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, audioData[i]));
|
||||
pcmData[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
addDebugLog(`Converted to 16-bit PCM: ${pcmData.length} samples`);
|
||||
|
||||
const wavHeader = createWaveHeader(pcmData.length * 2, {
|
||||
sampleRate: 16000,
|
||||
numChannels: 1,
|
||||
bitDepth: 16
|
||||
});
|
||||
|
||||
const wavBlob = new Blob([wavHeader, pcmData], { type: 'audio/wav' });
|
||||
addDebugLog(`Created WAV blob: ${(wavBlob.size / 1024).toFixed(2)} KB`);
|
||||
return wavBlob;
|
||||
} catch (err) {
|
||||
const errorMsg = `Error creating WAV blob: ${err.message}`;
|
||||
addDebugLog(errorMsg);
|
||||
throw new Error('Failed to process audio recording');
|
||||
}
|
||||
}, [addDebugLog]);
|
||||
|
||||
// Handle submit - stable implementation
|
||||
const handleSubmit = useCallback(async () => {
|
||||
let fileToSubmit;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const apiName = API_OPTIONS.find(api => api.id === selectedApi)?.name;
|
||||
setStatus(`Processing with ${apiName}...`);
|
||||
setError(null);
|
||||
addDebugLog(`Starting submission with ${apiName}`);
|
||||
|
||||
if (audioBlob) {
|
||||
addDebugLog('Processing recorded audio blob');
|
||||
fileToSubmit = await createWavBlob(audioBlob);
|
||||
} else if (file) {
|
||||
addDebugLog('Processing uploaded file');
|
||||
fileToSubmit = file;
|
||||
} else {
|
||||
const errorMsg = 'No audio file selected';
|
||||
addDebugLog(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
if (fileToSubmit.size > MAX_FILE_SIZE_BYTES) {
|
||||
const errorMsg = `File size exceeds ${MAX_FILE_SIZE_MB}MB limit`;
|
||||
addDebugLog(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('audio', fileToSubmit, 'audio.wav');
|
||||
addDebugLog(`Created FormData with ${(fileToSubmit.size / 1024).toFixed(2)} KB file`);
|
||||
|
||||
const apiConfig = API_OPTIONS.find(api => api.id === selectedApi);
|
||||
if (!apiConfig) {
|
||||
const errorMsg = 'Selected API not found';
|
||||
addDebugLog(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
addDebugLog(`Sending request to ${apiConfig.endpoint}`);
|
||||
const apiResponse = await fetch(apiConfig.endpoint, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!apiResponse.ok) {
|
||||
let errorMessage = `API returned error status: ${apiResponse.status}`;
|
||||
try {
|
||||
const errorData = await apiResponse.json();
|
||||
errorMessage = errorData.message || errorData.error || errorMessage;
|
||||
} catch (e) {
|
||||
addDebugLog('Failed to parse error response');
|
||||
}
|
||||
addDebugLog(`API error: ${errorMessage}`);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const result = await apiResponse.json();
|
||||
addDebugLog('Received successful response from API');
|
||||
setResponse({
|
||||
api: selectedApi,
|
||||
data: result
|
||||
});
|
||||
setStatus('Processing complete');
|
||||
} catch (err) {
|
||||
const errorMsg = err.message.includes('Failed to fetch')
|
||||
? 'Network error: Could not connect to the API server'
|
||||
: err.message;
|
||||
addDebugLog(`Error during submission: ${errorMsg}`);
|
||||
setError(errorMsg);
|
||||
setStatus('Processing failed');
|
||||
setResponse(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
addDebugLog('Submission process completed');
|
||||
}
|
||||
}, [selectedApi, audioBlob, file, createWavBlob, addDebugLog]);
|
||||
|
||||
// Helper functions
|
||||
const createWaveHeader = useCallback((dataLength, config) => {
|
||||
const byteRate = config.sampleRate * config.numChannels * (config.bitDepth / 8);
|
||||
const blockAlign = config.numChannels * (config.bitDepth / 8);
|
||||
|
||||
const buffer = new ArrayBuffer(44);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
writeString(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataLength, true);
|
||||
writeString(view, 8, 'WAVE');
|
||||
writeString(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, config.numChannels, true);
|
||||
view.setUint32(24, config.sampleRate, true);
|
||||
view.setUint32(28, byteRate, true);
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, config.bitDepth, true);
|
||||
writeString(view, 36, 'data');
|
||||
view.setUint32(40, dataLength, true);
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}, []);
|
||||
|
||||
const writeString = useCallback((view, offset, string) => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = useCallback((text) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
addDebugLog('Text copied to clipboard');
|
||||
}).catch(err => {
|
||||
const errorMsg = 'Failed to copy text to clipboard';
|
||||
addDebugLog(`${errorMsg}: ${err.message}`);
|
||||
setError(errorMsg);
|
||||
});
|
||||
}, [addDebugLog]);
|
||||
|
||||
// Fixed getDisplayText to prevent infinite loops
|
||||
const displayText = useMemo(() => {
|
||||
if (!response?.data) {
|
||||
addDebugLog('No response data to display');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof response.data === 'string') {
|
||||
return response.data;
|
||||
}
|
||||
if (response.data.text) {
|
||||
return response.data.text;
|
||||
}
|
||||
if (response.data.transcript) {
|
||||
return response.data.transcript;
|
||||
}
|
||||
if (response.data.results?.[0]?.alternatives?.[0]?.transcript) {
|
||||
return response.data.results[0].alternatives[0].transcript;
|
||||
}
|
||||
|
||||
return "Received response but couldn't extract text. View full response for details.";
|
||||
}, [response, addDebugLog]);
|
||||
|
||||
// Recording functions
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
addDebugLog('Attempting to start recording');
|
||||
setStatus("Requesting microphone access...");
|
||||
|
||||
if (audioBlob) {
|
||||
addDebugLog('Clearing previous recording');
|
||||
setAudioBlob(null);
|
||||
setAudioUrl(null);
|
||||
}
|
||||
|
||||
// Initialize audio context
|
||||
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: 16000
|
||||
});
|
||||
addDebugLog(`AudioContext created with sample rate: ${audioContextRef.current.sampleRate}`);
|
||||
|
||||
// Get user media
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
addDebugLog('Microphone access granted, stream created');
|
||||
|
||||
// Setup visualization
|
||||
setupVisualizer(stream);
|
||||
|
||||
// Initialize MediaRecorder
|
||||
mediaRecorderRef.current = new MediaRecorder(stream);
|
||||
audioChunksRef.current = [];
|
||||
|
||||
mediaRecorderRef.current.ondataavailable = (e) => {
|
||||
audioChunksRef.current.push(e.data);
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.onstop = () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
setAudioBlob(audioBlob);
|
||||
setAudioUrl(URL.createObjectURL(audioBlob));
|
||||
setStatus("Recording stopped. Ready to process.");
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.start(100); // Collect data every 100ms
|
||||
setIsRecording(true);
|
||||
setStatus("Recording (16kHz, 16-bit mono)...");
|
||||
addDebugLog('Recording started');
|
||||
|
||||
} catch (err) {
|
||||
const errorMsg = `Error starting recording: ${err.message}`;
|
||||
addDebugLog(errorMsg);
|
||||
setError(errorMsg);
|
||||
setStatus("Recording failed");
|
||||
setIsRecording(false);
|
||||
}
|
||||
}, [audioBlob, addDebugLog]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
addDebugLog('Stop recording initiated');
|
||||
if (!isRecording) {
|
||||
addDebugLog('Not currently recording, ignoring stop request');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsRecording(false);
|
||||
addDebugLog('Recording state updated to false');
|
||||
|
||||
if (mediaRecorderRef.current?.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => {
|
||||
track.stop();
|
||||
addDebugLog(`Stopped track: ${track.kind}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
animationRef.current = null;
|
||||
addDebugLog('Visualization animation stopped');
|
||||
}
|
||||
|
||||
addDebugLog('Recording successfully stopped');
|
||||
} catch (err) {
|
||||
const errorMsg = `Error stopping recording: ${err.message}`;
|
||||
addDebugLog(errorMsg);
|
||||
setError(errorMsg);
|
||||
setStatus("Recording stop failed");
|
||||
}
|
||||
}, [isRecording, addDebugLog]);
|
||||
|
||||
const playRecording = useCallback(() => {
|
||||
if (audioUrl) {
|
||||
addDebugLog('Playing recording');
|
||||
const audio = new Audio(audioUrl);
|
||||
audio.play();
|
||||
setStatus("Playing recording...");
|
||||
|
||||
audio.onended = () => {
|
||||
addDebugLog('Playback finished');
|
||||
setStatus("Playback finished");
|
||||
};
|
||||
} else {
|
||||
addDebugLog('No audio URL available for playback');
|
||||
}
|
||||
}, [audioUrl, addDebugLog]);
|
||||
|
||||
const setupVisualizer = useCallback((stream) => {
|
||||
if (!audioContextRef.current) {
|
||||
addDebugLog('AudioContext not available for visualization');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = audioContextRef.current.createMediaStreamSource(stream);
|
||||
analyserRef.current = audioContextRef.current.createAnalyser();
|
||||
analyserRef.current.fftSize = 64;
|
||||
source.connect(analyserRef.current);
|
||||
addDebugLog('Visualizer audio nodes connected');
|
||||
|
||||
const bufferLength = analyserRef.current.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
|
||||
const draw = () => {
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const barWidth = (canvas.width / bufferLength) * 2.5;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = dataArray[i] / 2;
|
||||
|
||||
ctx.fillStyle = `rgb(${barHeight + 100}, 50, 50)`;
|
||||
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
|
||||
|
||||
x += barWidth + 1;
|
||||
}
|
||||
};
|
||||
|
||||
draw();
|
||||
addDebugLog('Visualizer animation started');
|
||||
} catch (err) {
|
||||
addDebugLog(`Error setting up visualizer: ${err.message}`);
|
||||
}
|
||||
}, [addDebugLog]);
|
||||
|
||||
// Helper functions
|
||||
const formatTime = useCallback((seconds) => {
|
||||
const mins = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||
const secs = (seconds % 60).toString().padStart(2, '0');
|
||||
return `${mins}:${secs}`;
|
||||
}, []);
|
||||
|
||||
const clearDebugLogs = useCallback(() => {
|
||||
setDebugLogs([]);
|
||||
addDebugLog('Debug logs cleared');
|
||||
}, [addDebugLog]);
|
||||
|
||||
const toggleDebug = useCallback(() => {
|
||||
setShowDebug(!showDebug);
|
||||
addDebugLog(`Debug panel ${showDebug ? 'hidden' : 'shown'}`);
|
||||
}, [showDebug, addDebugLog]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 max-w-4xl my-6">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Speech to Text Converter</h1>
|
||||
<Button
|
||||
onClick={toggleDebug}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{showDebug ? 'Hide Debug' : 'Show Debug'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Recording Section */}
|
||||
<div className="mb-8 p-4 border border-dashed border-[#6d9e37] rounded-lg bg-gray-50">
|
||||
<h2 className="text-lg font-semibold mb-3 text-gray-700">Record Audio (16kHz, 16-bit mono)</h2>
|
||||
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-20 bg-gray-200 rounded mb-3"
|
||||
style={{ display: isRecording ? 'block' : 'none' }}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<Button
|
||||
onClick={startRecording}
|
||||
disabled={isRecording || isLoading}
|
||||
variant="outline"
|
||||
className="px-4 py-2"
|
||||
>
|
||||
Start Recording
|
||||
</Button>
|
||||
<Button
|
||||
onClick={stopRecording}
|
||||
disabled={!isRecording || isLoading}
|
||||
variant="outline"
|
||||
className="px-4 py-2"
|
||||
>
|
||||
Stop Recording
|
||||
</Button>
|
||||
<Button
|
||||
onClick={playRecording}
|
||||
disabled={!audioUrl || isRecording || isLoading}
|
||||
variant="outline"
|
||||
className="px-4 py-2"
|
||||
>
|
||||
Play Recording
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isRecording && (
|
||||
<div className="text-center text-[#6d9e37] font-medium">
|
||||
Recording: {formatTime(recordingTime)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audioUrl && !isRecording && (
|
||||
<div className="flex items-center justify-between bg-white p-3 rounded border mt-2">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-[#6d9e37] mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-[#6d9e37]">recording.wav</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#6d9e37]">
|
||||
{(audioBlob?.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-center w-full mb-3 -mt-2">
|
||||
<span className="flex-grow border-b-2 border-[#6d9e37]"></span>
|
||||
<h2 className="mx-2 text-[#6d9e37] font-bold">OR</h2>
|
||||
<span className="flex-grow border-b-2 border-[#6d9e37]"></span>
|
||||
</div>
|
||||
|
||||
{/* File Upload Section */}
|
||||
<div className="mb-8 p-4 border border-dashed border-[#6d9e37] rounded-lg bg-gray-50">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Upload Audio File
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">Supports WAV, MP3, OGG formats (max {MAX_FILE_SIZE_MB}MB)</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
variant="outline"
|
||||
className="px-4 py-2"
|
||||
disabled={isLoading || isRecording}
|
||||
>
|
||||
Select File
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept="audio/*,.wav,.mp3,.ogg"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
disabled={isLoading || isRecording}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div className="flex items-center justify-between bg-white p-3 rounded border">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-[#6d9e37] mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-[#6d9e37]">{file.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-[#6d9e37]">
|
||||
{(file.size / (1024 * 1024)).toFixed(2)} MB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Selection Section */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-3 text-gray-700">Select Speech Recognition API</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{API_OPTIONS.map(api => (
|
||||
<div
|
||||
key={api.id}
|
||||
onClick={() => !isLoading && !isRecording && setSelectedApi(api.id)}
|
||||
className={`p-4 border-[1.5px] rounded-lg cursor-pointer transition-all ${
|
||||
selectedApi === api.id
|
||||
? 'border-[1.5px] border-[#6d9e37] bg-[#6d9e3720]'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
} ${isLoading || isRecording ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
checked={selectedApi === api.id}
|
||||
onChange={() => {}}
|
||||
className="mr-2 h-4 w-4 accent-[#6d9e37]"
|
||||
disabled={isLoading || isRecording}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="font-bold text-[#6d9e37]">{api.name}</h3>
|
||||
<p className="text-xs text-gray-500">{api.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={(!file && !audioBlob) || isLoading || isRecording ||
|
||||
(file && file.size > MAX_FILE_SIZE_BYTES)}
|
||||
className="px-6 py-3 text-lg w-full md:w-auto"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
`Convert with ${API_OPTIONS.find(api => api.id === selectedApi)?.name}`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-6 text-center">
|
||||
<p className={`text-sm ${
|
||||
error ? 'text-red-600' :
|
||||
isLoading ? 'text-[#6d9e37]' :
|
||||
isRecording ? 'text-[#6d9e37]' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center text-red-600">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="font-medium">Error</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
{error.includes(`${MAX_FILE_SIZE_MB}MB`) && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
Please select a smaller audio file or record a shorter audio
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Display */}
|
||||
{response && (
|
||||
<div className="mt-6 border rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b flex justify-between items-center">
|
||||
<h3 className="font-medium text-[#6d9e37]">{API_OPTIONS.find(api => api.id === response.api)?.name} Results</h3>
|
||||
{displayText && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => copyToClipboard(displayText)}
|
||||
className="text-sm">
|
||||
{copied ? (<span className="flex items-center">Copied!</span>) : 'Copy Text'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white">
|
||||
{displayText ? (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-700">Transcription:</h4>
|
||||
<div className="relative">
|
||||
<p className="p-3 bg-gray-50 rounded text-sm text-gray-600 font-medium">{displayText}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-600">
|
||||
No text transcription found in the response.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Section */}
|
||||
{showDebug && (
|
||||
<div className="mt-8 border rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b flex justify-between items-center">
|
||||
<h3 className="font-medium text-gray-700">Debug Logs</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={clearDebugLogs}
|
||||
className="text-sm">
|
||||
Clear Logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const blob = new Blob([debugLogs.join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `audio-recorder-debug-${new Date().toISOString()}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
className="text-sm">
|
||||
Export Logs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-white max-h-60 overflow-y-auto">
|
||||
{debugLogs.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{debugLogs.map((log, index) => (
|
||||
<div key={index} className="text-xs font-mono text-gray-600 border-b border-gray-100 pb-1">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No debug logs yet. Interactions will appear here.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
445
src/components/Tools/FreeTextToSpeech.jsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
const FreeTextToSpeech = () => {
|
||||
// State management
|
||||
const [text, setText] = useState('');
|
||||
const [voices, setVoices] = useState([]);
|
||||
const [selectedVoice, setSelectedVoice] = useState('');
|
||||
const [pitch, setPitch] = useState(1);
|
||||
const [rate, setRate] = useState(1);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [status, setStatus] = useState('Enter text to convert to speech');
|
||||
const [isSpeaking, setIsSpeaking] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [debugLogs, setDebugLogs] = useState([]);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
|
||||
// Refs
|
||||
const synthRef = useRef(null);
|
||||
const utteranceRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
// Debug logging
|
||||
const addDebugLog = (message) => {
|
||||
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
||||
const logMessage = `${timestamp}: ${message}`;
|
||||
setDebugLogs(prev => [...prev.slice(-100), logMessage]);
|
||||
console.debug(logMessage);
|
||||
};
|
||||
|
||||
// Initialize speech synthesis
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
synthRef.current = window.speechSynthesis;
|
||||
|
||||
const loadVoices = () => {
|
||||
const availableVoices = synthRef.current.getVoices();
|
||||
setVoices(availableVoices);
|
||||
if (availableVoices.length > 0) {
|
||||
setSelectedVoice(availableVoices[0].name);
|
||||
}
|
||||
addDebugLog(`Loaded ${availableVoices.length} voices`);
|
||||
};
|
||||
|
||||
// Chrome loads voices asynchronously
|
||||
synthRef.current.onvoiceschanged = loadVoices;
|
||||
loadVoices();
|
||||
|
||||
return () => {
|
||||
if (synthRef.current) {
|
||||
synthRef.current.onvoiceschanged = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle speaking
|
||||
const handleSpeak = () => {
|
||||
if (!text.trim()) {
|
||||
setError('Please enter some text to speak');
|
||||
setStatus('Please enter some text to speak');
|
||||
addDebugLog('No text provided for speech');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!synthRef.current) {
|
||||
setError('Speech synthesis not supported in this browser');
|
||||
setStatus('Speech synthesis not supported');
|
||||
addDebugLog('Speech synthesis API not available');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaused) {
|
||||
synthRef.current.resume();
|
||||
setIsPaused(false);
|
||||
setIsSpeaking(true);
|
||||
setStatus('Resumed speaking');
|
||||
addDebugLog('Resumed speech playback');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any current speech
|
||||
synthRef.current.cancel();
|
||||
|
||||
// Create a new utterance
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utteranceRef.current = utterance;
|
||||
|
||||
// Set utterance properties
|
||||
const selectedVoiceObj = voices.find(v => v.name === selectedVoice);
|
||||
if (selectedVoiceObj) {
|
||||
utterance.voice = selectedVoiceObj;
|
||||
addDebugLog(`Using voice: ${selectedVoiceObj.name} (${selectedVoiceObj.lang})`);
|
||||
}
|
||||
utterance.pitch = pitch;
|
||||
utterance.rate = rate;
|
||||
utterance.volume = volume;
|
||||
|
||||
// Event handlers
|
||||
utterance.onstart = () => {
|
||||
setIsSpeaking(true);
|
||||
setIsPaused(false);
|
||||
setStatus('Speaking...');
|
||||
addDebugLog('Speech started');
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
setStatus('Finished speaking');
|
||||
addDebugLog('Speech completed');
|
||||
};
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
setStatus(`Error occurred: ${event.error}`);
|
||||
addDebugLog(`Speech error: ${event.error}`);
|
||||
};
|
||||
|
||||
// Speak the utterance
|
||||
synthRef.current.speak(utterance);
|
||||
setStatus('Started speaking...');
|
||||
addDebugLog('Initiated speech synthesis');
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
if (isSpeaking && !isPaused && synthRef.current) {
|
||||
synthRef.current.pause();
|
||||
setIsPaused(true);
|
||||
setIsSpeaking(false);
|
||||
setStatus('Paused');
|
||||
addDebugLog('Speech paused');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (synthRef.current) {
|
||||
synthRef.current.cancel();
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
setStatus('Stopped speaking');
|
||||
addDebugLog('Speech stopped');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
setStatus('Download requires a TTS API service');
|
||||
setError('Download functionality requires a TTS API service');
|
||||
addDebugLog('Download attempted (not implemented)');
|
||||
};
|
||||
|
||||
// Copy text to clipboard
|
||||
const copyToClipboard = () => {
|
||||
if (!text) return;
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
addDebugLog('Text copied to clipboard');
|
||||
}).catch(err => {
|
||||
const errorMsg = 'Failed to copy text to clipboard';
|
||||
addDebugLog(`${errorMsg}: ${err.message}`);
|
||||
setError(errorMsg);
|
||||
});
|
||||
};
|
||||
|
||||
// Clear all inputs and states
|
||||
const clearAll = () => {
|
||||
setText('');
|
||||
setError(null);
|
||||
setStatus('Enter text to convert to speech');
|
||||
|
||||
if (synthRef.current) {
|
||||
synthRef.current.cancel();
|
||||
}
|
||||
|
||||
setIsSpeaking(false);
|
||||
setIsPaused(false);
|
||||
|
||||
addDebugLog('All inputs and states cleared');
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const clearDebugLogs = () => {
|
||||
setDebugLogs([]);
|
||||
addDebugLog('Debug logs cleared');
|
||||
};
|
||||
|
||||
const toggleDebug = () => {
|
||||
setShowDebug(!showDebug);
|
||||
addDebugLog(`Debug panel ${showDebug ? 'hidden' : 'shown'}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 max-w-4xl my-6">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Text to Speech (Using Google)</h1>
|
||||
<Button
|
||||
onClick={toggleDebug}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{showDebug ? 'Hide Debug' : 'Show Debug'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Text Input Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label htmlFor="tts-text" className="block text-sm font-medium text-gray-700">
|
||||
Enter Text
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
disabled={!text || isSpeaking}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy Text'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
disabled={isSpeaking}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="tts-text"
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:border-[#6d9e37] bg-white text-gray-800"
|
||||
placeholder="Type or paste your text here..."
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
disabled={isSpeaking}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-6 p-4 border border-gray-200 rounded-lg">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Voice Selection */}
|
||||
<div>
|
||||
<label htmlFor="voice-select" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Select Voice
|
||||
</label>
|
||||
<select
|
||||
id="voice-select"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:border-[#6d9e37]"
|
||||
value={selectedVoice}
|
||||
onChange={(e) => setSelectedVoice(e.target.value)}
|
||||
disabled={isSpeaking}
|
||||
>
|
||||
{voices.length > 0 ? (
|
||||
voices.map((voice) => (
|
||||
<option key={voice.name} value={voice.name}>
|
||||
{voice.name} ({voice.lang})
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="">Loading voices...</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Pitch Control */}
|
||||
<div>
|
||||
<label htmlFor="pitch" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Pitch: {pitch.toFixed(1)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
id="pitch"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={pitch}
|
||||
onChange={(e) => setPitch(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#6d9e37]"
|
||||
disabled={isSpeaking}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rate Control */}
|
||||
<div>
|
||||
<label htmlFor="rate" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Speed: {rate.toFixed(1)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
id="rate"
|
||||
min="0.5"
|
||||
max="2"
|
||||
step="0.1"
|
||||
value={rate}
|
||||
onChange={(e) => setRate(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#6d9e37]"
|
||||
disabled={isSpeaking}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div>
|
||||
<label htmlFor="volume" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Volume: {volume.toFixed(1)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
id="volume"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-[#6d9e37]"
|
||||
disabled={isSpeaking}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<Button
|
||||
onClick={handleSpeak}
|
||||
disabled={!text.trim() || (isSpeaking && !isPaused)}
|
||||
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||
>
|
||||
{isPaused ? 'Resume' : 'Speak'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handlePause}
|
||||
disabled={!isSpeaking || isPaused}
|
||||
variant="outline"
|
||||
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleStop}
|
||||
disabled={!isSpeaking && !isPaused}
|
||||
variant="outline"
|
||||
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
variant="outline"
|
||||
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||
>
|
||||
Download Audio
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-6 text-center">
|
||||
<p className={`text-sm ${
|
||||
error ? 'text-red-600' :
|
||||
isSpeaking ? 'text-[#6d9e37]' :
|
||||
isPaused ? 'text-amber-600' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center text-red-600">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="font-medium">Error</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug Section */}
|
||||
{showDebug && (
|
||||
<div className="mt-8 border rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b flex justify-between items-center">
|
||||
<h3 className="font-medium text-gray-700">Debug Logs</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={clearDebugLogs}
|
||||
className="text-sm">
|
||||
Clear Logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const blob = new Blob([debugLogs.join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `tts-debug-${new Date().toISOString()}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
className="text-sm">
|
||||
Export Logs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-white max-h-60 overflow-y-auto">
|
||||
{debugLogs.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{debugLogs.map((log, index) => (
|
||||
<div key={index} className="text-xs font-mono text-gray-800 border-b border-gray-100 pb-1">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No debug logs yet. Interactions will appear here.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FreeTextToSpeech;
|
||||
295
src/components/Tools/ImageLabeling.jsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
function App() {
|
||||
const [images, setImages] = useState(Array(9).fill({ url: null, secretKey: null }));
|
||||
const [loading, setLoading] = useState(Array(9).fill(true));
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
|
||||
// Fetch initial images with staggered loading
|
||||
useEffect(() => {
|
||||
const fetchInitialImages = async () => {
|
||||
try {
|
||||
const newImages = [];
|
||||
const newLoading = [];
|
||||
|
||||
for (let i = 0; i < 9; i++) {
|
||||
setTimeout(async () => {
|
||||
const imageData = await fetchImage();
|
||||
setImages(prev => {
|
||||
const updated = [...prev];
|
||||
updated[i] = imageData;
|
||||
return updated;
|
||||
});
|
||||
setLoading(prev => {
|
||||
const updated = [...prev];
|
||||
updated[i] = false;
|
||||
return updated;
|
||||
});
|
||||
}, i * 150); // Staggered loading effect
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setLoading(Array(9).fill(false));
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialImages();
|
||||
}, []);
|
||||
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
const response = await fetch('https://api-img-lbeling-jugtfrvbghysvfr.siliconpin.com/get-image');
|
||||
if (!response.ok) throw new Error('Failed to fetch image');
|
||||
return await response.json();
|
||||
} catch (err) {
|
||||
console.error('Error fetching image:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const submitScore = async (imageData, score, index) => {
|
||||
try {
|
||||
setLoading(prev => {
|
||||
const newLoading = [...prev];
|
||||
newLoading[index] = true;
|
||||
return newLoading;
|
||||
});
|
||||
|
||||
const fileName = imageData.url.split('/').pop();
|
||||
const response = await fetch('https://api-img-lbeling-jugtfrvbghysvfr.siliconpin.com/save-score', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fileName: fileName,
|
||||
score: score,
|
||||
secretKey: imageData.secretKey
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to submit score');
|
||||
|
||||
// Animate out the current image
|
||||
setImages(prev => {
|
||||
const newImages = [...prev];
|
||||
newImages[index] = { ...newImages[index], exiting: true };
|
||||
return newImages;
|
||||
});
|
||||
|
||||
// Wait for animation to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
// Get new image and animate it in
|
||||
const newImage = await fetchImage();
|
||||
setImages(prev => {
|
||||
const newImages = [...prev];
|
||||
newImages[index] = { ...newImage, entering: true };
|
||||
return newImages;
|
||||
});
|
||||
|
||||
// Remove animation flags after entrance
|
||||
setTimeout(() => {
|
||||
setImages(prev => {
|
||||
const newImages = [...prev];
|
||||
newImages[index] = { ...newImages[index], entering: false };
|
||||
return newImages;
|
||||
});
|
||||
}, 300);
|
||||
} catch (err) {
|
||||
console.error('Error submitting score:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(prev => {
|
||||
const newLoading = [...prev];
|
||||
newLoading[index] = false;
|
||||
return newLoading;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const renderStarButtons = (imageData, index) => {
|
||||
if (!imageData.url) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex justify-center gap-2 mt-4"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map(star => (
|
||||
<motion.button
|
||||
key={star}
|
||||
onClick={() => submitScore(imageData, star, index)}
|
||||
disabled={loading[index]}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className={`
|
||||
px-4 py-2 rounded-full border-2 font-medium text-sm
|
||||
transition-colors duration-200
|
||||
${loading[index] ?
|
||||
'bg-gray-100 border-gray-200 text-gray-400 cursor-not-allowed' :
|
||||
`bg-white ${star >= 3 ?
|
||||
'border-emerald-300 text-emerald-600 hover:bg-emerald-50' :
|
||||
'border-amber-300 text-amber-600 hover:bg-amber-50'}`
|
||||
}
|
||||
`}
|
||||
>
|
||||
{star} ★
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<motion.div
|
||||
className="flex items-center justify-center min-h-screen bg-gradient-to-br from-blue-50 to-purple-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<motion.div
|
||||
className="max-w-md p-8 bg-white rounded-2xl shadow-xl text-center"
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
>
|
||||
<div className="text-red-500 text-2xl font-bold mb-4">Oops!</div>
|
||||
<div className="text-gray-600 mb-6">{error}</div>
|
||||
<motion.button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-6 py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-xl hover:shadow-md transition-all"
|
||||
whileHover={{ scale: 1.03 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
>
|
||||
Try Again
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen py-12 px-4 sm:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold mb-3 text-[#6d9e37]">Image Rating App</h1>
|
||||
<p className="text-lg max-w-2xl mx-auto">Rate these beautiful images from 1 to 5 stars. Your feedback helps improve our collection!</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{images.map((imageData, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
layout
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{
|
||||
opacity: loading[index] && !imageData.url ? 0.7 : 1,
|
||||
y: 0
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 15,
|
||||
delay: index * 0.05
|
||||
}}
|
||||
className={`relative bg-white rounded-2xl shadow-lg overflow-hidden
|
||||
transition-all hover:shadow-xl ${loading[index] ? 'animate-pulse' : ''}`}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{imageData.url ? (
|
||||
<motion.div
|
||||
key={`image-${index}`}
|
||||
initial={{ opacity: imageData.exiting ? 1 : 0, scale: imageData.exiting ? 1 : 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="h-72 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 p-4 relative">
|
||||
<motion.img
|
||||
src={imageData.url}
|
||||
alt={`Image ${index}`}
|
||||
className="max-h-full max-w-full object-contain cursor-pointer"
|
||||
onClick={() => setSelectedImage(imageData.url)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
onError={(e) => {
|
||||
e.target.onerror = null;
|
||||
e.target.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2QxZDFkMSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0zIDNoMTh2MThIM3oiLz48cGF0aCBkPSJNMTkgOUw5IDE5Ii8+PHBhdGggZD0iTTkgOWwxMCAxMCIvPjwvc3ZnPg==';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{renderStarButtons(imageData, index)}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key={`skeleton-${index}`}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.7 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-72 bg-gradient-to-br from-gray-100 to-gray-200 flex items-center justify-center"
|
||||
>
|
||||
<div className="text-gray-400">Loading image...</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{loading[index] && imageData.url && (
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-white bg-opacity-80 flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
|
||||
className="w-10 h-10 border-4 border-blue-500 border-t-transparent rounded-full"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedImage && (
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{ scale: 1 }}
|
||||
exit={{ scale: 0.9 }}
|
||||
className="relative max-w-4xl w-full"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="absolute -top-10 right-0 text-white text-3xl hover:text-gray-300 transition"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={selectedImage}
|
||||
alt="Enlarged view"
|
||||
className="max-h-[80vh] w-full object-contain"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
132
src/components/Tools/ImageResize.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { fromBlob, blobToURL } from "image-resize-compress";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ImageIcon,
|
||||
DownloadIcon,
|
||||
SlidersHorizontal,
|
||||
FileImage,
|
||||
} from "lucide-react";
|
||||
|
||||
export default function ImageResize() {
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
const [resizedBlob, setResizedBlob] = useState(null);
|
||||
const [imgQuality, setImgQuality] = useState(80);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [originalSize, setOriginalSize] = useState(0);
|
||||
const [resizedSize, setResizedSize] = useState(0);
|
||||
const [originalExtension, setOriginalExtension] = useState("webp");
|
||||
|
||||
useEffect(() => {
|
||||
const resizeImage = async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
try {
|
||||
const width = "auto";
|
||||
const height = "auto";
|
||||
const ext = selectedFile.name.split(".").pop().toLowerCase();
|
||||
|
||||
setOriginalExtension(ext);
|
||||
setOriginalSize((selectedFile.size / 1024).toFixed(2)); // KB
|
||||
|
||||
// Only apply quality change for JPEG or WebP
|
||||
const format = ["jpg", "jpeg", "webp"].includes(ext)
|
||||
? ext
|
||||
: "jpeg"; // fallback to jpeg for better quality control
|
||||
|
||||
const resized = await fromBlob(selectedFile, imgQuality, width, height, format);
|
||||
const url = await blobToURL(resized);
|
||||
|
||||
setResizedBlob(resized);
|
||||
setResizedSize((resized.size / 1024).toFixed(2)); // KB
|
||||
setPreviewUrl(url);
|
||||
} catch (err) {
|
||||
console.error("Image resizing failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
resizeImage();
|
||||
}, [selectedFile, imgQuality]);
|
||||
|
||||
const handleFileChange = (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!resizedBlob) return;
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(resizedBlob);
|
||||
link.download = `resized-image.${originalExtension}`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen text-white p-6 flex items-center justify-center">
|
||||
<div className="bg-gray-800 rounded-2xl shadow-lg w-full max-w-2xl p-6 space-y-6">
|
||||
<h2 className="text-2xl font-bold flex items-center gap-2 text-[#6d9e37]">
|
||||
<ImageIcon className="w-6 h-6" />
|
||||
Image Resizer
|
||||
</h2>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300 font-medium">
|
||||
<FileImage className="w-4 h-4" />
|
||||
Choose an image
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="w-full p-2 bg-gray-700 border border-gray-600 rounded-lg text-sm file:bg-[#6d9e37] file:text-white file:border-0 file:px-3 file:py-1"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium flex items-center gap-1">
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
Quality: <span className="text-[#6d9e37]">{imgQuality}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
value={imgQuality}
|
||||
onChange={(e) => setImgQuality(e.target.value)}
|
||||
className="w-full accent-[#6d9e37]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{previewUrl && (
|
||||
<div className="border-t border-gray-700 pt-4 space-y-3">
|
||||
<div className="text-sm text-gray-300 grid grid-cols-2 gap-2">
|
||||
<p>
|
||||
<strong>Original Size:</strong> {originalSize} KB
|
||||
</p>
|
||||
<p>
|
||||
<strong>Resized Size:</strong> {resizedSize} KB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-400 mb-2">Preview:</p>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Resized Preview"
|
||||
className="w-full h-auto max-h-96 object-contain rounded-lg border border-gray-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
className="mt-4 w-full gap-2"
|
||||
>
|
||||
<DownloadIcon className="w-5 h-5" />
|
||||
Download Resized Image
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
375
src/components/Tools/TextToSpeech.jsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
const TTS_API_ENDPOINT = 'https://tts41-nhdtuisbdhcvdth.siliconpin.com/tts';
|
||||
|
||||
export default function TextToSpeech() {
|
||||
// State management
|
||||
const [text, setText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [audioUrl, setAudioUrl] = useState(null);
|
||||
const [status, setStatus] = useState('Enter text to convert to speech');
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [debugLogs, setDebugLogs] = useState([]);
|
||||
const [showDebug, setShowDebug] = useState(false);
|
||||
|
||||
// Refs
|
||||
const audioRef = useRef(null);
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
// Debug logging
|
||||
const addDebugLog = useCallback((message) => {
|
||||
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
||||
const logMessage = `${timestamp}: ${message}`;
|
||||
setDebugLogs(prev => [...prev.slice(-100), logMessage]);
|
||||
console.debug(logMessage);
|
||||
}, []);
|
||||
|
||||
// Clean up audio URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
addDebugLog('Component unmounting - revoking audio URL');
|
||||
}
|
||||
};
|
||||
}, [audioUrl, addDebugLog]);
|
||||
|
||||
// Handle text to speech conversion
|
||||
const convertToSpeech = useCallback(async () => {
|
||||
if (!text.trim()) {
|
||||
setError('Please enter some text to convert');
|
||||
addDebugLog('No text provided for conversion');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setStatus('Converting text to speech...');
|
||||
setError(null);
|
||||
addDebugLog(`Starting TTS conversion for text: "${text.substring(0, 20)}..."`);
|
||||
|
||||
const response = await fetch(TTS_API_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: text.trim() }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMsg = `API returned error status: ${response.status}`;
|
||||
addDebugLog(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const audioBlob = await response.blob();
|
||||
const url = URL.createObjectURL(audioBlob);
|
||||
|
||||
setAudioUrl(url);
|
||||
setStatus('Conversion complete. Ready to play or download.');
|
||||
addDebugLog(`TTS conversion successful. Audio blob size: ${(audioBlob.size / 1024).toFixed(2)} KB`);
|
||||
} catch (err) {
|
||||
const errorMsg = err.message.includes('Failed to fetch')
|
||||
? 'Network error: Could not connect to the TTS server'
|
||||
: err.message;
|
||||
addDebugLog(`Error during TTS conversion: ${errorMsg}`);
|
||||
setError(errorMsg);
|
||||
setStatus('Conversion failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
addDebugLog('TTS conversion process completed');
|
||||
}
|
||||
}, [text, addDebugLog]);
|
||||
|
||||
// Handle audio download
|
||||
const downloadAudio = useCallback(() => {
|
||||
if (!audioUrl) return;
|
||||
|
||||
addDebugLog('Initiating audio download');
|
||||
const a = document.createElement('a');
|
||||
a.href = audioUrl;
|
||||
a.download = `tts-output-${new Date().toISOString().slice(0, 10)}.wav`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setStatus('Audio download started');
|
||||
addDebugLog('Audio download initiated');
|
||||
}, [audioUrl, addDebugLog]);
|
||||
|
||||
// Handle audio playback
|
||||
const togglePlayback = useCallback(() => {
|
||||
if (!audioUrl) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
setIsPlaying(false);
|
||||
setStatus('Playback stopped');
|
||||
addDebugLog('Playback stopped');
|
||||
} else {
|
||||
audioRef.current.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
setStatus('Playing audio...');
|
||||
addDebugLog('Playback started');
|
||||
})
|
||||
.catch(err => {
|
||||
setError('Failed to play audio');
|
||||
addDebugLog(`Playback error: ${err.message}`);
|
||||
});
|
||||
}
|
||||
}, [audioUrl, isPlaying, addDebugLog]);
|
||||
|
||||
// Handle audio playback end
|
||||
const handleAudioEnd = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
setStatus('Playback finished');
|
||||
addDebugLog('Playback finished naturally');
|
||||
}, [addDebugLog]);
|
||||
|
||||
// Copy text to clipboard
|
||||
const copyToClipboard = useCallback(() => {
|
||||
if (!text) return;
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
addDebugLog('Text copied to clipboard');
|
||||
}).catch(err => {
|
||||
const errorMsg = 'Failed to copy text to clipboard';
|
||||
addDebugLog(`${errorMsg}: ${err.message}`);
|
||||
setError(errorMsg);
|
||||
});
|
||||
}, [text, addDebugLog]);
|
||||
|
||||
// Clear all inputs and states
|
||||
const clearAll = useCallback(() => {
|
||||
setText('');
|
||||
setError(null);
|
||||
setStatus('Enter text to convert to speech');
|
||||
|
||||
if (audioUrl) {
|
||||
URL.revokeObjectURL(audioUrl);
|
||||
setAudioUrl(null);
|
||||
}
|
||||
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
setIsPlaying(false);
|
||||
}
|
||||
|
||||
addDebugLog('All inputs and states cleared');
|
||||
}, [audioUrl, isPlaying, addDebugLog]);
|
||||
|
||||
// Helper functions
|
||||
const clearDebugLogs = useCallback(() => {
|
||||
setDebugLogs([]);
|
||||
addDebugLog('Debug logs cleared');
|
||||
}, [addDebugLog]);
|
||||
|
||||
const toggleDebug = useCallback(() => {
|
||||
setShowDebug(!showDebug);
|
||||
addDebugLog(`Debug panel ${showDebug ? 'hidden' : 'shown'}`);
|
||||
}, [showDebug, addDebugLog]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 max-w-4xl my-6">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className='flex flex-col space-y-2'>
|
||||
<h1 className="text-2xl font-bold text-gray-800">Text to Speech (Using our API)</h1>
|
||||
<p className='text-gray-500'>Limitation: 5 Req / Min & 50 Req / Day </p>
|
||||
<p className='text-gray-500'>Need More Purchase from our <a href="/services" className='text-[#6d9e37] font-bold'>Services</a></p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={toggleDebug}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{showDebug ? 'Hide Debug' : 'Show Debug'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Text Input Section */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label htmlFor="tts-text" className="block text-sm font-medium text-gray-700">
|
||||
Enter Text
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
disabled={!text || isLoading}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy Text'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="tts-text"
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:border-[#6d9e37] bg-white text-gray-800"
|
||||
placeholder="Type or paste your text here..."
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
disabled={isLoading}
|
||||
maxLength={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3 mb-6">
|
||||
<Button
|
||||
onClick={convertToSpeech}
|
||||
disabled={!text.trim() || isLoading}
|
||||
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Converting...
|
||||
</span>
|
||||
) : (
|
||||
'Convert to Speech'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={togglePlayback}
|
||||
disabled={!audioUrl || isLoading}
|
||||
variant="outline"
|
||||
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Stop Playback
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Play Audio
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={downloadAudio}
|
||||
disabled={!audioUrl || isLoading}
|
||||
variant="outline"
|
||||
className="px-6 py-3 text-lg flex-1 min-w-[200px]"
|
||||
>
|
||||
<span className="flex items-center justify-center">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download Audio
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-6 text-center">
|
||||
<p className={`text-sm ${
|
||||
error ? 'text-red-600' :
|
||||
isLoading ? 'text-[#6d9e37]' :
|
||||
isPlaying ? 'text-[#6d9e37]' :
|
||||
'text-gray-600'
|
||||
}`}>
|
||||
{status}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center text-red-600">
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h3 className="font-medium">Error</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio Player (hidden) */}
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
onEnded={handleAudioEnd}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Debug Section */}
|
||||
{showDebug && (
|
||||
<div className="mt-8 border rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b flex justify-between items-center">
|
||||
<h3 className="font-medium text-gray-700">Debug Logs</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={clearDebugLogs}
|
||||
className="text-sm">
|
||||
Clear Logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const blob = new Blob([debugLogs.join('\n')], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `tts-debug-${new Date().toISOString()}.log`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
className="text-sm">
|
||||
Export Logs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 bg-white max-h-60 overflow-y-auto">
|
||||
{debugLogs.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{debugLogs.map((log, index) => (
|
||||
<div key={index} className="text-xs font-mono text-gray-800 border-b border-gray-100 pb-1">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">No debug logs yet. Interactions will appear here.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/components/Tools/ToolsCard.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../../components/ui/card";
|
||||
import { Button } from "../ui/button";
|
||||
export default function ToolsCard({key, toolName, toolSlug, toolFeatures, toolImg}) {
|
||||
// console.log('tool data', {key, toolName, toolSlug, toolFeatures, toolImg})
|
||||
return(
|
||||
<>
|
||||
<Card className="flex flex-col justify-between hover:shadow-[0_0_10px_#6d9e37] duration-500">
|
||||
<img className="object-cover aspect-video rounded-t-md" src={toolImg} alt={toolName} />
|
||||
|
||||
<CardContent className="flex flex-col flex-1">
|
||||
<CardTitle className="py-2">{toolName}</CardTitle>
|
||||
<hr className="border-b-2 border-[#6d9e37]" />
|
||||
|
||||
|
||||
<ul className="space-y-2 text-sm mb-4 mt-2">
|
||||
{
|
||||
toolFeatures.map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-[#6d9e37] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
<span className='text-zinc-600'>{feature}</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto">
|
||||
<a href={`/web-tools/${toolSlug}`}>
|
||||
<Button onClick={() => window.location.href=`/web-tools/${toolSlug}`} className="w-full">View</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
230
src/components/TopicDetail.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import ImageSlider from "./ImageSlider/ImageSlider"
|
||||
import { marked } from 'marked';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Button } from "./ui/button";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import { token, user_name, pb_id } from '../lib/CookieValues';
|
||||
import CommentSystem from './CommentSystem/CommentSystem';
|
||||
import '../styles/markdown.css';
|
||||
import LikeSystem from './CommentSystem/LikeSystem'
|
||||
|
||||
const COMMENTS_API_URL = 'https://host-api-sxashuasysagibx.siliconpin.com/v1/comments/';
|
||||
export default function TopicDetail(props) {
|
||||
// console.log('coockie data', user_name)
|
||||
const [showCopied, setShowCopied] = useState(false);
|
||||
const [allGalleryImages, setAllGalleryImages] = useState([]);
|
||||
const siliconId = '4uPRIGMm';
|
||||
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3NTI5MjQxMTQsImlkIjoiZ203NzYyZ2Q2aTNscDAxIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0.Aq02zEAcXjyfsFO6MniM0atzzy2rSXx7B_rnilK6OYQ';
|
||||
if (!props.topic) {
|
||||
return <div>Topic not found</div>;
|
||||
}
|
||||
const slug = props.topic.slug;
|
||||
const fetchGalleryImage = async () => {
|
||||
try{
|
||||
const response = await fetch(`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com/gallery-image-delevery?slug=${slug}`)
|
||||
const data = await response.json();
|
||||
setAllGalleryImages(data);
|
||||
// console.log('data.images', data.images)
|
||||
}catch(error){
|
||||
// console.error(error)
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchGalleryImage()
|
||||
}, [slug])
|
||||
const shareUrl = typeof window !== 'undefined' ? window.location.href : '';
|
||||
const title = props.topic.title;
|
||||
const text = `Check out this Topic: ${title}`;
|
||||
|
||||
const shareOnSocialMedia = (platform) => {
|
||||
switch (platform){
|
||||
case 'facebook':
|
||||
window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`, '_blank');
|
||||
break;
|
||||
|
||||
case 'twitter':
|
||||
window.open(`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`, '_blank');
|
||||
break;
|
||||
|
||||
case 'linkedin':
|
||||
window.open(`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`, '_blank');
|
||||
break;
|
||||
|
||||
case 'reddit':
|
||||
window.open(`https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`, '_blank');
|
||||
break;
|
||||
|
||||
case 'whatsapp':
|
||||
window.open(`https://wa.me/?text=${encodeURIComponent(`${text} ${shareUrl}`)}`, '_blank');
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
window.open(`mailto:?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(`${text}\n\n${shareUrl}`)}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(shareUrl);
|
||||
setShowCopied(true);
|
||||
setTimeout(() => setShowCopied(false), 2000);
|
||||
};
|
||||
|
||||
// SVG Icons with improved styling
|
||||
const SocialIcon = ({ children, className = "" }) => (
|
||||
<span className={`w-5 h-5 flex items-center justify-center ${className}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
const FacebookIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TwitterIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="100" height="100" viewBox="0 0 30 30" fill="currentColor">
|
||||
<path d="M26.37,26l-8.795-12.822l0.015,0.012L25.52,4h-2.65l-6.46,7.48L11.28,4H4.33l8.211,11.971L12.54,15.97L3.88,26h2.65 l7.182-8.322L19.42,26H26.37z M10.23,6l12.34,18h-2.1L8.12,6H10.23z"></path>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LinkedInIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RedditIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.5.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.12-.07 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.963-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WhatsAppIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const MailIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M0 3v18h24v-18h-24zm6.623 7.929l-4.623 5.712v-9.458l4.623 3.746zm-4.141-5.929h19.035l-9.517 7.713-9.518-7.713zm5.694 7.188l3.824 3.099 3.83-3.104 5.612 6.817h-18.779l5.513-6.812zm9.208-1.264l4.616-3.741v9.348l-4.616-5.607z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LinkIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6.188 8.719c.439-.439.926-.801 1.444-1.087 2.887-1.591 6.589-.745 8.445 2.069l-2.246 2.245c-.644-1.469-2.243-2.305-3.834-1.949-.599.134-1.168.433-1.633.898l-4.304 4.306c-1.307 1.307-1.307 3.433 0 4.74 1.307 1.307 3.433 1.307 4.74 0l1.327-1.327c1.207.479 2.501.67 3.779.575l-2.929 2.929c-2.511 2.511-6.582 2.511-9.093 0s-2.511-6.582 0-9.093l4.304-4.306zm6.836-6.836l-2.929 2.929c1.277-.096 2.572.096 3.779.574l1.326-1.326c1.307-1.307 3.433-1.307 4.74 0 1.307 1.307 1.307 3.433 0 4.74l-4.305 4.305c-1.311 1.311-3.44 1.3-4.74 0-.303-.303-.564-.68-.727-1.051l-2.246 2.245c.236.358.481.689.736 1.011.748.98 1.804 1.644 2.993 1.907 1.535.368 3.159.166 4.613-.617.518-.286 1.005-.648 1.444-1.087l4.304-4.305c2.512-2.511 2.512-6.582.001-9.093-2.511-2.51-6.581-2.51-9.092 0z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<article className="max-w-4xl mx-auto">
|
||||
<img src={props.topic.img ? props.topic.img : '/assets/images/thumb-place.jpg'} alt={props.topic.title} className="w-full object-cover rounded-lg mb-8 shadow-md" />
|
||||
<h1 className="text-4xl font-bold text-[#6d9e37] mb-6">{props.topic.title}</h1>
|
||||
{/* Enhanced Social Share Buttons */}
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-row justify-between">
|
||||
<p className="text-sm text-gray-500 mb-3 font-medium">Share this Topic:</p>
|
||||
<LikeSystem postId={props.topic.id} siliconId={siliconId} token={token} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => shareOnSocialMedia('facebook')}
|
||||
className="flex items-center gap-1 px-2 py-2 bg-[#3b5998] hover:bg-[#2d4373] text-white rounded-lg transition-all shadow-sm hover:shadow-md active:scale-95"
|
||||
aria-label="Share on Facebook"
|
||||
title="Share on Facebook"
|
||||
>
|
||||
<SocialIcon className="text-white"><FacebookIcon /></SocialIcon>
|
||||
<span className="text-sm font-medium">Facebook</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => shareOnSocialMedia('whatsapp')}
|
||||
className="flex items-center gap-1 px-2 py-2 bg-[#25D366] hover:bg-[#1da851] text-white rounded-lg transition-all shadow-sm hover:shadow-md active:scale-95"
|
||||
aria-label="Share on WhatsApp"
|
||||
title="Share on WhatsApp"
|
||||
>
|
||||
<SocialIcon className="text-white"><WhatsAppIcon /></SocialIcon>
|
||||
<span className="text-sm font-medium">WhatsApp</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => shareOnSocialMedia('twitter')}
|
||||
className="flex items-center gap-1 px-2 py-2 bg-[#000000] hover:bg-[#000000] text-white rounded-lg transition-all shadow-sm hover:shadow-md active:scale-95"
|
||||
aria-label="Share on Twitter"
|
||||
title="Share on Twitter"
|
||||
>
|
||||
<SocialIcon className="text-white"><TwitterIcon /></SocialIcon>
|
||||
<span className="text-sm font-medium">Twitter</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => shareOnSocialMedia('linkedin')}
|
||||
className="flex items-center gap-1 px-2 py-2 bg-[#0077b5] hover:bg-[#005582] text-white rounded-lg transition-all shadow-sm hover:shadow-md active:scale-95"
|
||||
aria-label="Share on LinkedIn"
|
||||
title="Share on LinkedIn"
|
||||
>
|
||||
<SocialIcon className="text-white"><LinkedInIcon /></SocialIcon>
|
||||
<span className="text-sm font-medium">LinkedIn</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => shareOnSocialMedia('reddit')}
|
||||
className="flex items-center gap-1 px-2 py-2 bg-[#FF5700] hover:bg-[#e04e00] text-white rounded-lg transition-all shadow-sm hover:shadow-md active:scale-95"
|
||||
aria-label="Share on Reddit"
|
||||
title="Share on Reddit"
|
||||
>
|
||||
<SocialIcon className="text-white"><RedditIcon /></SocialIcon>
|
||||
<span className="text-sm font-medium">Reddit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => shareOnSocialMedia('reddit')}
|
||||
className="flex items-center gap-1 px-2 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-all shadow-sm hover:shadow-md active:scale-95"
|
||||
aria-label="Share via Email"
|
||||
title="Share via Email"
|
||||
>
|
||||
<SocialIcon className="text-white"><MailIcon /></SocialIcon>
|
||||
<span className="text-sm font-medium">Email</span>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="flex items-center gap-1 px-2 py-2 bg-gray-800 hover:bg-gray-900 text-white rounded-lg transition-all shadow-sm hover:shadow-md active:scale-95"
|
||||
aria-label="Copy link"
|
||||
title="Copy link to share"
|
||||
>
|
||||
<SocialIcon className="text-white"><LinkIcon /></SocialIcon>
|
||||
<span className="text-sm font-medium">Copy Link</span>
|
||||
</button>
|
||||
{showCopied && (
|
||||
<div className="absolute -bottom-8 left-1/2 transform -translate-x-1/2 bg-gray-800 text-white text-xs px-2 py-1 rounded whitespace-nowrap shadow-md">
|
||||
Link copied!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="markdown-content" className="table-scroll-wrapper font-light mb-8 text-justify prose max-w-none leading-8" dangerouslySetInnerHTML={{ __html: marked.parse(props.topic.content || '') }} ></div>
|
||||
{
|
||||
allGalleryImages && (
|
||||
<ImageSlider images={allGalleryImages} />
|
||||
)
|
||||
}
|
||||
<CommentSystem topicId={props.topic.id}/>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// bg-[#6d9e37]
|
||||
656
src/components/TopicEdit.jsx
Normal file
@@ -0,0 +1,656 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import MDEditor, { commands } from '@uiw/react-md-editor';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from './ui/select';
|
||||
import { Button } from './ui/button';
|
||||
import { Separator } from './ui/separator';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog';
|
||||
import { CustomTabs } from './ui/tabs';
|
||||
import Loader from "./ui/loader";
|
||||
import Cookies from 'js-cookie';
|
||||
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||
const PUBLIC_MINIO_UPLOAD_URL = 'https://file-vault-prod-pdgctwgsnkiy.siliconpin.com/upload';
|
||||
// const PUBLIC_MINIO_UPLOAD_URL = import.meta.env.PUBLIC_MINIO_UPLOAD_URL;
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const slug = urlParams.get('slug');
|
||||
// console.log('find slug from url', slug);
|
||||
export default function EditTopic (){
|
||||
// const { slug } = useParams();
|
||||
// const navigate = useNavigate();
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({ status: 'draft', category: '', slug: '', title: '', content: '', imageUrl: '' });
|
||||
// UI state
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||
const [imageUrlInput, setImageUrlInput] = useState('');
|
||||
const [imageUploadFile, setImageUploadFile] = useState(null);
|
||||
const [imageUploadPreview, setImageUploadPreview] = useState('');
|
||||
const [editorMode, setEditorMode] = useState('edit');
|
||||
const [galleryImgDesc, setGalleryImgDesc] = useState('');
|
||||
const [galleryImg, setGalleryImg] = useState('');
|
||||
const [statusMessage, setStatusMessage] = useState({success : "", message: ""});
|
||||
const [allGalleryImages, setAllGalleryImages] = useState([]);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const fetchGalleryImage = async () => {
|
||||
try{
|
||||
const response = await fetch(`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com/gallery-image-delevery?slug=${slug}`)
|
||||
const data = await response.json();
|
||||
setAllGalleryImages(data.images);
|
||||
// console.log('data.images', data.images)
|
||||
}catch(error){
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch topic data on component mount
|
||||
useEffect(() => {
|
||||
const fetchTopic = async () => {
|
||||
try {
|
||||
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=get-single-topic&slug=${slug}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch topic');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const topic = data.data[0]
|
||||
// console.log('Single topic data', data)
|
||||
setFormData({
|
||||
status: topic.status || 'draft',
|
||||
category: topic.category || '',
|
||||
title: topic.title || '',
|
||||
slug: topic.slug || '',
|
||||
content: topic.content || '',
|
||||
imageUrl: topic.img || ''
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTopic(); fetchGalleryImage();
|
||||
}, [slug]);
|
||||
// Upload Gallery Images
|
||||
const uploadGalleryImages = async (file, slug) => {
|
||||
setIsSubmitting(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('description', galleryImgDesc);
|
||||
formData.append('slug', slug);
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success === true) {
|
||||
setGalleryImgDesc('');
|
||||
setStatusMessage({ success: true, message: "Image Uploaded! you can upload more images" });
|
||||
setGalleryImg(null);
|
||||
fetchGalleryImage();
|
||||
if (fileInputRef.current) fileInputRef.current.value = null;
|
||||
} else {
|
||||
setStatusMessage({ success: false, message: "Failed to upload image" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatusMessage({ success: false, message: "Failed to upload image" });
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
// Upload file to MinIO (same as NewTopic)
|
||||
const uploadToMinIO = async (file, onProgress) => {
|
||||
const siliconId = Cookies.get('siliconId'); // Get from cookie or anywhere
|
||||
const token = Cookies.get('token'); // Get from cookie or storage
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(PUBLIC_MINIO_UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-user-data': siliconId,
|
||||
'Authorization': token,
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// console.log('Upload successful:', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Generate slug from title (same as NewTopic)
|
||||
// useEffect(() => {
|
||||
// if (formData.title) {
|
||||
// const newSlug = formData.title
|
||||
// .toLowerCase()
|
||||
// .replace(/[^\w\s]/g, '')
|
||||
// .replace(/\s+/g, '-');
|
||||
// setFormData(prev => ({ ...prev, slug: newSlug }));
|
||||
// }
|
||||
// }, [formData.title]);
|
||||
|
||||
// Custom image command for MDEditor (same as NewTopic)
|
||||
const customImageCommand = {
|
||||
name: 'image',
|
||||
keyCommand: 'image',
|
||||
buttonProps: { 'aria-label': 'Insert image' },
|
||||
icon: (
|
||||
<svg width="12" height="12" viewBox="0 0 20 20">
|
||||
<path fill="currentColor" d="M15 9c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm4-7H1c-.55 0-1 .45-1 1v14c0 .55.45 1 1 1h18c.55 0 1-.45 1-1V3c0-.55-.45-1-1-1zm-1 13l-6-5-2 2-4-5-4 8V4h16v11z"/>
|
||||
</svg>
|
||||
),
|
||||
execute: () => {
|
||||
setImageDialogOpen(true);
|
||||
},
|
||||
};
|
||||
|
||||
// Custom <br> insert command
|
||||
const lineBreakCommand = {
|
||||
name: 'linebreak',
|
||||
keyCommand: 'linebreak',
|
||||
buttonProps: { 'aria-label': 'Insert line break' },
|
||||
icon: (
|
||||
|
||||
<svg width="16px" height="16px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path d="M20 7V8.2C20 9.88016 20 10.7202 19.673 11.362C19.3854 11.9265 18.9265 12.3854 18.362 12.673C17.7202 13 16.8802 13 15.2 13H4M4 13L8 9M4 13L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path> </g></svg>
|
||||
),
|
||||
execute: () => {
|
||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
|
||||
const value = formData.content || "";
|
||||
const breakTag = '<br>';
|
||||
|
||||
const newValue = value.slice(0, start) + breakTag + value.slice(end);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
content: newValue
|
||||
}));
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + breakTag.length;
|
||||
textarea.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Get all commands (same as NewTopic)
|
||||
const allCommands = [
|
||||
...commands.getCommands().map(cmd => {
|
||||
if (cmd.name === 'image') {
|
||||
return customImageCommand;
|
||||
}
|
||||
return cmd;
|
||||
}),
|
||||
lineBreakCommand,
|
||||
];
|
||||
|
||||
// Handle image URL insertion (same as NewTopic)
|
||||
const handleInsertImageUrl = () => {
|
||||
if (imageUrlInput) {
|
||||
const imgMarkdown = ``;
|
||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||
if (textarea) {
|
||||
const startPos = textarea.selectionStart;
|
||||
const endPos = textarea.selectionEnd;
|
||||
const currentValue = formData.content;
|
||||
|
||||
const newValue =
|
||||
currentValue.substring(0, startPos) +
|
||||
imgMarkdown +
|
||||
currentValue.substring(endPos);
|
||||
|
||||
setFormData(prev => ({ ...prev, content: newValue }));
|
||||
}
|
||||
setImageDialogOpen(false);
|
||||
setImageUrlInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle image file selection (same as NewTopic)
|
||||
const handleImageFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setImageUploadFile(file);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setImageUploadPreview(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload image file (same as NewTopic)
|
||||
const handleImageUpload = async () => {
|
||||
if (!imageUploadFile) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
const uploadedUrl = await uploadToMinIO(imageUploadFile, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
});
|
||||
|
||||
const imgMarkdown = ``;
|
||||
const textarea = document.querySelector('.w-md-editor-text-input');
|
||||
if (textarea) {
|
||||
const startPos = textarea.selectionStart;
|
||||
const endPos = textarea.selectionEnd;
|
||||
const currentValue = formData.content;
|
||||
|
||||
const newValue =
|
||||
currentValue.substring(0, startPos) +
|
||||
imgMarkdown +
|
||||
currentValue.substring(endPos);
|
||||
|
||||
setFormData(prev => ({ ...prev, content: newValue }));
|
||||
}
|
||||
|
||||
setImageDialogOpen(false);
|
||||
setImageUploadFile(null);
|
||||
setImageUploadPreview('');
|
||||
} catch (error) {
|
||||
setError('Failed to upload image: ' + error.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Form submission for EDIT
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
try {
|
||||
// Upload new featured image if selected
|
||||
let imageUrl = formData.imageUrl;
|
||||
if (imageFile) {
|
||||
imageUrl = await uploadToMinIO(imageFile, (progress) => {
|
||||
setUploadProgress(progress);
|
||||
});
|
||||
}
|
||||
imageUrl = imageUrl.url;
|
||||
// Prepare payload
|
||||
const payload = {
|
||||
...formData,
|
||||
imageUrl
|
||||
};
|
||||
|
||||
// Submit to API (PUT request for update)
|
||||
const response = await fetch(`${PUBLIC_TOPIC_API_URL}?query=update-topic&slug=${formData.slug}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to update topic');
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
navigate(`/topic/${formData.slug}`);
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form field changes (same as NewTopic)
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Handle featured image file selection (same as NewTopic)
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setImageFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setFormData(prev => ({ ...prev, imageUrl: reader.result }));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Loader />
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (success) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 text-center py-8">
|
||||
<h3 className="text-xl font-semibold mb-4">Topic has been successfully updated!</h3>
|
||||
<p className="mb-4">You are being automatically redirected to the topic page...</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button onClick={() => window.location.href = `/topic/${formData.slug}`}>Preview this Topic</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-3xl mx-auto bg-neutral-800 rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-[#6d9e37] mb-2">Edit Topic</h2>
|
||||
<p className="text-gray-600 mb-6">{formData.title}</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
name="status"
|
||||
required
|
||||
value={formData.status}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<SelectTrigger id="status" className="text-sm sm:text-base w-full">
|
||||
<SelectValue placeholder="Select Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="published">Publish</SelectItem>
|
||||
<SelectItem value="archived">Archive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category *</Label>
|
||||
<Select
|
||||
name="category"
|
||||
required
|
||||
value={formData.category}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, category: value }))}
|
||||
>
|
||||
<SelectTrigger id="category" className="text-sm sm:text-base w-full">
|
||||
<SelectValue placeholder="Select Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" className="z-50">
|
||||
<SelectItem value="php">PHP Hosting</SelectItem>
|
||||
<SelectItem value="nodejs">Node.js Hosting</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title and Slug */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title *</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Wriet Topic Title"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* <Label htmlFor="slug">URL Slug</Label> */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="slug"
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
{/* <p className="text-xs text-gray-500">This will be used in the topic URL</p> */}
|
||||
</div>
|
||||
|
||||
{/* Featured Image Upload */}
|
||||
<div className="space-y-2">
|
||||
<Label >Featured Image</Label>
|
||||
<div className="flex flex-wrap space-x-4">
|
||||
<Label htmlFor="image" className="cursor-pointer">
|
||||
<svg viewBox="0 0 512 512" width="100px" height="100px" fill="#000000">
|
||||
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
|
||||
<g id="SVGRepo_tracerCarrier" strokeLinecap="round" strokeLinejoin="round"></g>
|
||||
<g id="SVGRepo_iconCarrier">
|
||||
<path fill="#6d9e37" d="M512,0h-40v16h24v32h16V0z M432,0h-40v16h40V0z M352,0h-40v16h40V0z M272,0h-40v16h40V0z M192,0h-40 v16h40V0z M112,0H72v16h40V0z M32,0H0v16h32V0z M16,48H0v40h16V48z M16,128H0v40h16V128z M16,208H0v40h16V208z M16,288H0v40h16V288z M16,368H0v40h16V368z M16,448H0v40h16V448z M56,496H16v16h40V496z M136,496H96v16h40V496z M216,496h-40v16h40V496z M296,496h-40v16 h40V496z M376,496h-40v16h40V496z M456,496h-40v16h40V496z M512,488h-16v8l0,0v16h16V488z M512,408h-16v40h16V408z M512,328h-16v40 h16V328z M512,248h-16v40h16V248z M512,168h-16v40h16V168z M512,88h-16v40h16V88z"></path>
|
||||
<g>
|
||||
<rect x="244" y="175.976" fill="#6d9e37" width="24" height="160.08"></rect>
|
||||
<rect x="175.976" y="244" fill="#6d9e37" width="160.08" height="24"></rect>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</Label>
|
||||
<Input
|
||||
type="file"
|
||||
id="image"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
className="cursor-pointer hidden"
|
||||
/>
|
||||
{formData.imageUrl && (
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={formData.imageUrl}
|
||||
alt="Preview"
|
||||
className="w-[100px] h-[100px] rounded-md border"
|
||||
/>
|
||||
<button onClick={(e) => {e.preventDefault(); setFormData({...formData, imageUrl : null})}} className="absolute inset-0 opacity-0 group-hover:opacity-100 bg-white/20 transition-all duration-500 text-2xl font-bold text-red-500">X</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Editor */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="content">Content *</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode(editorMode === 'edit' ? 'preview' : 'edit')}
|
||||
className={`ml-2 ${editorMode !== 'edit' ? 'bg-[#6d9e37]' : ''} text-white border border-[#6d9e37] text-[#6d9e37] px-2 py-1 rounded-md`}
|
||||
>
|
||||
{editorMode === 'edit' ? 'Preview' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
<div data-color-mode="light">
|
||||
<MDEditor
|
||||
value={formData.content}
|
||||
onChange={(value) => setFormData(prev => ({ ...prev, content: value || '' }))}
|
||||
height={400}
|
||||
preview={editorMode}
|
||||
commands={allCommands}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image Upload Dialog (same as NewTopic) */}
|
||||
<Dialog open={imageDialogOpen} onOpenChange={setImageDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Image</DialogTitle>
|
||||
</DialogHeader>
|
||||
<CustomTabs
|
||||
tabs={[
|
||||
{
|
||||
label: "URL",
|
||||
value: "url",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Image URL"
|
||||
value={imageUrlInput}
|
||||
onChange={(e) => setImageUrlInput(e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInsertImageUrl}
|
||||
disabled={!imageUrlInput}
|
||||
>
|
||||
Upload Image
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
label: "Upload",
|
||||
value: "upload",
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageFileSelect}
|
||||
/>
|
||||
{imageUploadPreview && (
|
||||
<div className="mt-2">
|
||||
<img
|
||||
src={imageUploadPreview}
|
||||
alt="Preview"
|
||||
className="max-h-40 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleImageUpload}
|
||||
disabled={!imageUploadFile || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Uploading...' : 'Upload'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{
|
||||
allGalleryImages && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 xl:grid-cols-6 gap-4 mt-4">
|
||||
{allGalleryImages.map((img, index) => (
|
||||
<img key={index} src={`https://gallery-image-pocndhvgmcnbacgb.siliconpin.com${img}`} alt={`gallery-${index}`} className="w-full h-auto rounded shadow" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div>
|
||||
<Label htmlFor="galleryImage">
|
||||
Gallery Image: <span className="text-xs text-gray-500">(First write description (Min 10 Char) to proceed image upload!)</span>
|
||||
</Label>
|
||||
<div className="flex gap-x-4 mt-1">
|
||||
<Input
|
||||
onChange={(e) => setGalleryImgDesc(e.target.value)}
|
||||
value={galleryImgDesc}
|
||||
type="text"
|
||||
placeholder="Write description to proceed image upload!"
|
||||
/>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
onChange={(e) => setGalleryImg(e.target.files[0])}
|
||||
type="file"
|
||||
className={`${galleryImgDesc.trim().length > 9 ? 'border-[#6d9e37] file:bg-[#6d9e37] file:text-white file:border-0 file:rounded-md' : ''}`}
|
||||
disabled={galleryImgDesc.trim().length < 10}
|
||||
/>
|
||||
<Button onClick={() => uploadGalleryImages(galleryImg, slug)} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Uploading...' : 'Upload'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className={`mt-2 text-center ${statusMessage.success === true ? 'text-[#6d9e37]' : statusMessage.success === false ? 'text-red-500' : ''}`}>
|
||||
{statusMessage.success === true && <span className="mr-1">✓</span>}
|
||||
{statusMessage.success === false && <span className="mr-1">❌</span>}
|
||||
{statusMessage.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Form Actions */}
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate(-1)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="min-w-32 bg-[#6d9e37] hover:bg-[#5a8a2a]"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="animate-spin">↻</span>
|
||||
Uploading...
|
||||
</span>
|
||||
) : 'Update'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 text-red-600 rounded-md">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
100
src/components/TopicItem.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import { Pencil, Trash2, Search } from "lucide-react";
|
||||
import { marked } from 'marked';
|
||||
export default function TopicItems(props) {
|
||||
// console.table(props.topics)
|
||||
const { isLoggedIn, loading, error, sessionData } = useIsLoggedIn();
|
||||
const [localSearchTerm, setLocalSearchTerm] = React.useState(props.searchTerm || '');
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading-indicator">Loading...</div>;
|
||||
}
|
||||
|
||||
// if (error) {
|
||||
// return <div className="error-message">Error loading authentication status</div>;
|
||||
// }
|
||||
|
||||
const handleSearchChange = (e) => {
|
||||
setLocalSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
props.onSearch(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="container mx-auto px-4">
|
||||
<div className="py-8 text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-[#6d9e37] mb-3 sm:mb-4">{props.title}</h2>
|
||||
<p className="text-lg sm:text-xl max-w-3xl mx-auto text-neutral-300">{props.description}</p>
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 max-w-xl mx-auto mt-4">
|
||||
<Input type="text" name="search" placeholder="Search Topic..." value={localSearchTerm} onChange={handleSearchChange}/>
|
||||
<Button type="submit">
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Search
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
{
|
||||
props.topics.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{
|
||||
props.topics.map((topic) => (
|
||||
<a href={`/topic/${topic.slug}`} key={topic.id} className="hover:scale-[1.02] transition-transform duration-200 ">
|
||||
<Card className="h-full flex flex-col group relative">
|
||||
<div className="">
|
||||
<img src={topic.img ? topic.img : '/assets/images/thumb-place.jpg'} alt={topic.title} className="aspect-video object-cover rounded-t-lg" loading="lazy" />
|
||||
</div>
|
||||
<CardContent className="flex-1 p-6">
|
||||
<CardTitle className="mb-2 line-clamp-1">{topic.title}</CardTitle>
|
||||
<CardDescription className="line-clamp-6 mb-4 text-justify" dangerouslySetInnerHTML={{ __html: marked.parse(topic.content || '') }}></CardDescription>
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-gray-500"><strong>Author: </strong>{topic.user_name}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
<strong>Date: </strong>
|
||||
{new Date(topic.timestamp).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
year: '2-digit',
|
||||
month: 'short',
|
||||
}).replace(',', '')}
|
||||
</p>
|
||||
</div>
|
||||
{isLoggedIn && sessionData.user_email === topic.user && props.mytopic === true && (
|
||||
<div className="flex justify-end gap-2 mt-4 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<Button variant="outline" size="sm" className="gap-1"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `/topic/edit?slug=${topic.slug}`
|
||||
// Handle edit action
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />Edit
|
||||
</Button>
|
||||
<Button size="sm" className="gap-1 bg-red-500 hover:bg-red-600"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Handle delete action
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center">No Topic Found <a href="/topic/new" className="text-[#6d9e37]"> Click Here</a> to Create Once</p>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
232
src/components/Topics _Old_copy.jsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import TopicItems from "./TopicItem";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
const topicPageDesc = 'Cutting-edge discussions on tech, digital services, news, and digital freedom. Stay informed on AI, cybersecurity, privacy, and the future of innovation.';
|
||||
|
||||
export default function TopicCreation() {
|
||||
const { isLoggedIn, loading: authLoading, error: authError } = useIsLoggedIn();
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// Get current page from URL or default to 1
|
||||
const getCurrentPage = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const page = parseInt(params.get('page')) || 1;
|
||||
return Math.max(1, Math.min(page, pagination.last_page));
|
||||
};
|
||||
|
||||
// Fetch topics data with abort controller
|
||||
const fetchTopics = async (page) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://host-api-sxashuasysagibx.siliconpin.com/v1/topics/?query=get-all-topics&page=${page}`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTopics(data.data || []);
|
||||
setPagination(prev => ({
|
||||
...data.pagination,
|
||||
current_page: Math.min(data.pagination.current_page, data.pagination.last_page)
|
||||
}));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (newPage) => {
|
||||
const validatedPage = Math.max(1, Math.min(newPage, pagination.last_page));
|
||||
window.history.pushState({}, '', `?page=${validatedPage}`);
|
||||
fetchTopics(validatedPage);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Initial load and URL change handling
|
||||
useEffect(() => {
|
||||
fetchTopics(getCurrentPage());
|
||||
|
||||
const handlePopState = () => {
|
||||
fetchTopics(getCurrentPage());
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (authLoading) {
|
||||
return <div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
if (authError) {
|
||||
return <div className="error-message p-4 bg-red-100 text-red-700 rounded">Error loading authentication status</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoggedIn && (
|
||||
<div className="container mx-auto flex justify-end gap-x-4 mb-4">
|
||||
<a href="/topic/new" className="create-new-link">Create New</a>
|
||||
<a href="/topic/my-topic">My Topics</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !topics.length ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="error-message p-4 bg-red-100 text-red-700 rounded">Error loading topics: {error}</div>
|
||||
) : (
|
||||
<>
|
||||
<TopicItems topics={topics} title="SoliconPin Topics" description={topicPageDesc} />
|
||||
|
||||
{pagination.last_page > 1 && (
|
||||
<div className="flex flex-col justify-between items-center mt-8 gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {(pagination.current_page - 1) * pagination.per_page + 1}-
|
||||
{Math.min(pagination.current_page * pagination.per_page, pagination.total)} of {pagination.total} topics
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{
|
||||
pagination.current_page > 1 && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={pagination.current_page <= 1}
|
||||
className="hidden sm:inline-flex"
|
||||
>
|
||||
First
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(pagination.current_page - 1)}
|
||||
disabled={pagination.current_page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{generatePageNumbers(pagination.current_page, pagination.last_page).map((page, i) => (
|
||||
page === '...' ? (
|
||||
<span key={i} className="px-2">...</span>
|
||||
) : (
|
||||
<Button
|
||||
key={i}
|
||||
variant={page === pagination.current_page ? "default" : "outline"}
|
||||
onClick={() => handlePageChange(page)}
|
||||
className="min-w-10"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(pagination.current_page + 1)}
|
||||
disabled={pagination.current_page >= pagination.last_page}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(pagination.last_page)}
|
||||
disabled={pagination.current_page >= pagination.last_page}
|
||||
className="hidden sm:inline-flex"
|
||||
>
|
||||
Last
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to generate smart page numbers
|
||||
function generatePageNumbers(currentPage, lastPage) {
|
||||
const pages = [];
|
||||
const maxVisible = 5; // Maximum visible page numbers
|
||||
|
||||
if (lastPage <= maxVisible) {
|
||||
for (let i = 1; i <= lastPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
// Calculate start and end of middle pages
|
||||
let start = Math.max(2, currentPage - 1);
|
||||
let end = Math.min(lastPage - 1, currentPage + 1);
|
||||
|
||||
// Adjust if we're at the beginning
|
||||
if (currentPage <= 3) {
|
||||
end = maxVisible - 2;
|
||||
}
|
||||
|
||||
// Adjust if we're at the end
|
||||
if (currentPage >= lastPage - 2) {
|
||||
start = lastPage - (maxVisible - 2);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (start > 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Add middle pages
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (end < lastPage - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(lastPage);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
265
src/components/Topics.jsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import TopicItems from "./TopicItem";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import { Button } from "./ui/button";
|
||||
import Loader from "./ui/loader";
|
||||
const topicPageDesc = 'Cutting-edge discussions on tech, digital services, news, and digital freedom. Stay informed on AI, cybersecurity, privacy, and the future of innovation.';
|
||||
|
||||
export default function TopicCreation() {
|
||||
const PUBLIC_TOPIC_API_URL = import.meta.env.PUBLIC_TOPIC_API_URL;
|
||||
const { isLoggedIn, loading: authLoading, error: authError } = useIsLoggedIn();
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [pagination, setPagination] = useState({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 12,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// Get current page from URL or default to 1
|
||||
const getCurrentPage = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const page = parseInt(params.get('page')) || 1;
|
||||
return Math.max(1, Math.min(page, pagination.last_page));
|
||||
};
|
||||
|
||||
// Fetch topics data
|
||||
const fetchTopics = async (page, search = '') => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let url = `${PUBLIC_TOPIC_API_URL}?query=get-all-topics&page=${page}`;
|
||||
|
||||
if (search) {
|
||||
url += `&search=${encodeURIComponent(search)}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setTopics(data.data || []);
|
||||
setPagination(prev => ({
|
||||
...data.pagination,
|
||||
current_page: Math.min(data.pagination.current_page, data.pagination.last_page)
|
||||
}));
|
||||
setSearchTerm(data.search_term || '');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
console.error('Fetch error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (newPage) => {
|
||||
const validatedPage = Math.max(1, Math.min(newPage, pagination.last_page));
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('page', validatedPage);
|
||||
window.history.pushState({}, '', `?${params.toString()}`);
|
||||
fetchTopics(validatedPage, searchTerm);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// Handle search
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
const newSearchTerm = e.target.elements.search.value.trim();
|
||||
setSearchTerm(newSearchTerm);
|
||||
|
||||
// Reset to page 1 when searching
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('page', 1);
|
||||
if (newSearchTerm) {
|
||||
params.set('search', newSearchTerm);
|
||||
} else {
|
||||
params.delete('search');
|
||||
}
|
||||
window.history.pushState({}, '', `?${params.toString()}`);
|
||||
fetchTopics(1, newSearchTerm);
|
||||
};
|
||||
|
||||
// Initial load and URL change handling
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const initialSearch = params.get('search') || '';
|
||||
setSearchTerm(initialSearch);
|
||||
fetchTopics(getCurrentPage(), initialSearch);
|
||||
|
||||
const handlePopState = () => {
|
||||
const newParams = new URLSearchParams(window.location.search);
|
||||
const newSearch = newParams.get('search') || '';
|
||||
setSearchTerm(newSearch);
|
||||
fetchTopics(getCurrentPage(), newSearch);
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', handlePopState);
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handlePopState);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ... (keep existing authLoading and authError checks)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoggedIn && (
|
||||
<>
|
||||
<div className="container mx-auto flex justify-end gap-x-4 my-4">
|
||||
<Button className="hidden lg:block" onClick={() => window.location.href = '/topic/new'} variant="outline">Create New</Button>
|
||||
<Button onClick={() => window.location.href = '/topic/my-topic'} variant="outline">My Creation</Button>
|
||||
</div>
|
||||
<button onClick={() => window.location.href = '/topic/new'} className="fixed z-10 bottom-20 lg:bottom-10 right-0 lg:right-10 rounded-full w-16 h-16 bg-[#6d9e37]">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#6d9e37] opacity-75 duration-[1s,15s]"></span>
|
||||
<span class="relative inline-flex w-16 h-16 rounded-full bg-[#6d9e37]">
|
||||
<svg className="border-[2px] border-[#fff] rounded-full" fill="#FFFFFF" viewBox="-5.76 -5.76 35.52 35.52" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"> <path fill-rule="evenodd" d="M12.3023235,7.94519388 L4.69610276,15.549589 C4.29095108,15.9079238 4.04030835,16.4092335 4,16.8678295 L4,20.0029438 L7.06398288,20.004826 C7.5982069,19.9670062 8.09548693,19.7183782 8.49479322,19.2616227 L16.0567001,11.6997158 L12.3023235,7.94519388 Z M13.7167068,6.53115006 L17.4709137,10.2855022 L19.8647941,7.89162181 C19.9513987,7.80501747 20.0000526,7.68755666 20.0000526,7.56507948 C20.0000526,7.4426023 19.9513987,7.32514149 19.8647932,7.23853626 L16.7611243,4.13485646 C16.6754884,4.04854589 16.5589355,4 16.43735,4 C16.3157645,4 16.1992116,4.04854589 16.1135757,4.13485646 L13.7167068,6.53115006 Z M16.43735,2 C17.0920882,2 17.7197259,2.26141978 18.1781068,2.7234227 L21.2790059,5.82432181 C21.7406843,6.28599904 22.0000526,6.91216845 22.0000526,7.56507948 C22.0000526,8.21799052 21.7406843,8.84415992 21.2790068,9.30583626 L9.95750718,20.6237545 C9.25902448,21.4294925 8.26890003,21.9245308 7.1346,22.0023295 L2,22.0023295 L2,21.0023295 L2.00324765,16.7873015 C2.08843822,15.7328366 2.57866679,14.7523321 3.32649633,14.0934196 L14.6953877,2.72462818 C15.1563921,2.2608295 15.7833514,2 16.43735,2 Z"></path> </g></svg>
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading && !topics.length ? (
|
||||
<Loader />
|
||||
) : error ? (
|
||||
<div className="error-message p-4 bg-red-100 text-red-700 rounded">Error loading topics: {error}</div>
|
||||
) : (
|
||||
<>
|
||||
<TopicItems topics={topics} title="SoliconPin Topics" description={topicPageDesc} onSearch={handleSearch} searchTerm={searchTerm}/>
|
||||
|
||||
{pagination.last_page > 1 && (
|
||||
<div className="flex flex-col justify-between items-center mt-8 gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Showing {(pagination.current_page - 1) * pagination.per_page + 1}-
|
||||
{Math.min(pagination.current_page * pagination.per_page, pagination.total)} of {pagination.total} topics
|
||||
{searchTerm && (
|
||||
<span className="ml-2">matching "{searchTerm}"</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{
|
||||
pagination.current_page > 1 && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(1)}
|
||||
disabled={pagination.current_page <= 1}
|
||||
className="hidden sm:inline-flex"
|
||||
>
|
||||
First
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(pagination.current_page - 1)}
|
||||
disabled={pagination.current_page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{generatePageNumbers(pagination.current_page, pagination.last_page).map((page, i) => (
|
||||
page === '...' ? (
|
||||
<span key={i} className="px-2">...</span>
|
||||
) : (
|
||||
<Button
|
||||
key={i}
|
||||
variant={page === pagination.current_page ? "default" : "outline"}
|
||||
onClick={() => handlePageChange(page)}
|
||||
className="min-w-10"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(pagination.current_page + 1)}
|
||||
disabled={pagination.current_page >= pagination.last_page}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handlePageChange(pagination.last_page)}
|
||||
disabled={pagination.current_page >= pagination.last_page}
|
||||
className="hidden sm:inline-flex"
|
||||
>
|
||||
Last
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper function to generate smart page numbers
|
||||
function generatePageNumbers(currentPage, lastPage) {
|
||||
const pages = [];
|
||||
const maxVisible = 5; // Maximum visible page numbers
|
||||
|
||||
if (lastPage <= maxVisible) {
|
||||
for (let i = 1; i <= lastPage; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
// Calculate start and end of middle pages
|
||||
let start = Math.max(2, currentPage - 1);
|
||||
let end = Math.min(lastPage - 1, currentPage + 1);
|
||||
|
||||
// Adjust if we're at the beginning
|
||||
if (currentPage <= 3) {
|
||||
end = maxVisible - 2;
|
||||
}
|
||||
|
||||
// Adjust if we're at the end
|
||||
if (currentPage >= lastPage - 2) {
|
||||
start = lastPage - (maxVisible - 2);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (start > 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Add middle pages
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (end < lastPage - 1) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(lastPage);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { X } from "lucide-react"; // Import an icon for the remove button
|
||||
|
||||
export function AvatarUpload() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
setSelectedFile(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
const handleRemoveFile = () => {
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''; // Reset file input
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!selectedFile ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<Label
|
||||
htmlFor="avatar"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground py-2 px-4 text-sm rounded-md cursor-pointer transition-colorsfocus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
Change Avatar
|
||||
</Label>
|
||||
<Input type="file" id="avatar" ref={fileInputRef} accept="image/jpeg,image/png,image/gif" className="hidden" onChange={handleFileChange}/>
|
||||
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>Browse</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
JPG, GIF or PNG. 1MB max.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-3 space-x-2">
|
||||
<div className="truncate max-w-[200px]">
|
||||
<p className="text-sm font-medium truncate">{selectedFile.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{(selectedFile.size / 1024).toFixed(2)} KB</p>
|
||||
</div>
|
||||
<Button size="sm" className="text-xs p-1 h-fit">Update</Button>
|
||||
<Button size="sm" onClick={handleRemoveFile} className="bg-red-500 hover:bg-red-600 text-xs p-1 h-fit">Remove</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export default AvatarUpload;
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "./ui/card";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "./ui/select";
|
||||
import { Separator } from "./ui/separator";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import UpdateAvatar from './UpdateAvatar';
|
||||
|
||||
import {AvatarUpload} from './AvatarUpload';
|
||||
import {localizeTime} from "../lib/localizeTime";
|
||||
import Loader from "./ui/loader";
|
||||
import { Eye, Pencil, Trash2, Download, ChevronUp, ChevronDown, Search } from "lucide-react";
|
||||
import { PDFDownloadLink } from '@react-pdf/renderer';
|
||||
import InvoicePDF from "../lib/InvoicePDF";
|
||||
import { useIsLoggedIn } from '../lib/isLoggedIn';
|
||||
import PasswordUpdateCard from './PasswordUpdateCard';
|
||||
import Index from "../pages/topic/index.astro";
|
||||
interface SessionData {
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -18,19 +25,59 @@ interface UserData {
|
||||
session_data: SessionData;
|
||||
user_avatar: string;
|
||||
}
|
||||
interface BillingItems {
|
||||
[key: string]: any;
|
||||
}
|
||||
type ToastState = {
|
||||
visible: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type Invoice = {
|
||||
billing_id: string;
|
||||
};
|
||||
|
||||
type PaymentData = {
|
||||
txn_id: string;
|
||||
user_email: string;
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { isLoggedIn, loading, sessionData, balance } = useIsLoggedIn();
|
||||
const typedSessionData = sessionData as SessionData | null;
|
||||
const [userData, setUserData] = useState<UserData | null>(null);
|
||||
const [invoiceList, setInvoiceList] = useState<any[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedData, setSelectedData] = useState<BillingItems | null>(null);
|
||||
const [toast, setToast] = useState<ToastState>({ visible: false, message: '' });
|
||||
const [txnId, setTxnId] = useState<string>('');
|
||||
const [userEmail, setUserEmail] = useState<string>('');
|
||||
const [addBalanceModal, setAddBalanceModal] = useState(false);
|
||||
const [balanceData, setBalanceData] = useState([
|
||||
{
|
||||
currency: "INR",
|
||||
balanceList: ["100", "500", "1000", "1500"]
|
||||
},
|
||||
{
|
||||
currency: "USD",
|
||||
balanceList: ["10", "50", "100", "150"]
|
||||
}
|
||||
]);
|
||||
|
||||
const [selectedAmount, setSelectedAmount] = useState('');
|
||||
const [customAmount, setCustomAmount] = useState('');
|
||||
const [selectedCurrency, setSelectedCurrency] = useState("INR");
|
||||
const [balanceApiloading, setBalanceApiloading] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
|
||||
const PUBLIC_USER_API_URL = import.meta.env.PUBLIC_USER_API_URL;
|
||||
const PUBLIC_INVOICE_API_URL = import.meta.env.PUBLIC_INVOICE_API_URL;
|
||||
const currentBalanceList = balanceData.find(item => item.currency === selectedCurrency)?.balanceList || [];
|
||||
useEffect(() => {
|
||||
const fetchSessionData = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
'http://localhost:2058/host-api/v1/users/get-profile-data/',
|
||||
{
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=get-user`, {
|
||||
credentials: 'include', // Crucial for cookies
|
||||
headers: { 'Accept': 'application/json' }
|
||||
}
|
||||
@@ -49,7 +96,7 @@ export default function ProfilePage() {
|
||||
};
|
||||
const getInvoiceListData = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:2058/host-api/v1/invoice/invoice-info/', {
|
||||
const response = await fetch(`${PUBLIC_USER_API_URL}?query=invoice-info`, {
|
||||
method: 'GET',
|
||||
credentials: 'include', // Crucial for cookies
|
||||
headers: { 'Accept': 'application/json' }
|
||||
@@ -72,28 +119,128 @@ export default function ProfilePage() {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
fetchSessionData();
|
||||
getInvoiceListData();
|
||||
}, []);
|
||||
|
||||
const handleNext = async () => {
|
||||
const amountToSend = selectedAmount === "others" ? customAmount : selectedAmount;
|
||||
if (!amountToSend || isNaN(Number(amountToSend))) {
|
||||
setFormError("Please enter a valid amount.");
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('amount', amountToSend);
|
||||
formData.append('currency', selectedCurrency); // ✅ Include currency
|
||||
|
||||
try {
|
||||
setBalanceApiloading(true);
|
||||
|
||||
const response = await fetch(`https://siliconpin.com/v1/balance/?query=initiate-add-balance`, {
|
||||
method: "POST",
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success === true) {
|
||||
window.location.href = `/add-balance?orderId=${data.transaction_id}`;
|
||||
} else {
|
||||
alert("Something went wrong. Please try again.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("API Error:", error);
|
||||
alert("An error occurred while processing your request.");
|
||||
} finally {
|
||||
setBalanceApiloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
const showToast = (message: string) => {
|
||||
setToast({ visible: true, message });
|
||||
setTimeout(() => setToast({ visible: false, message: '' }), 3000);
|
||||
};
|
||||
|
||||
const handlePanelBuyNow = (invoice: Invoice) => {
|
||||
// setTxnId(data.txn_id);
|
||||
// setUserEmail(data.user_email);
|
||||
window.location.href = `/make-payment?query=get-initiated_payment&orderId=${invoice.billing_id}`;
|
||||
showToast('Redirecting to payment page...');
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleViewItems = (items: BillingItems) => {
|
||||
setDialogOpen(true);
|
||||
setSelectedData(items);
|
||||
};
|
||||
|
||||
const sessionLogOut = () => {
|
||||
fetch(`${PUBLIC_USER_API_URL}?query=logout`, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if(data.success === true){
|
||||
window.location.href = '/';
|
||||
}
|
||||
console.log('Logout Console', data.success);
|
||||
})
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
// Then handle not logged in state
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<p className="text-center my-8">
|
||||
You are not logged in! Please login first to access this page.{" "}
|
||||
<a className="text-[#6d9e37]" href="/login">Click Here</a> to login
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// Then handle error state
|
||||
if (error) {
|
||||
return <div>Error: {error}</div>;
|
||||
}
|
||||
|
||||
// Then handle case where user data hasn't loaded yet
|
||||
if (!userData) {
|
||||
return <div>Loading profile data...</div>;
|
||||
return <Loader />;
|
||||
}
|
||||
const formattedBalance = new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(balance);
|
||||
return (
|
||||
<div className="space-y-6 container mx-auto">
|
||||
<Separator />
|
||||
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
|
||||
<div className="flex-1 lg:max-w-2xl">
|
||||
<div className="flex-1 lg:max-w-3xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Personal Information</CardTitle>
|
||||
<div className="flex justify-between">
|
||||
<CardTitle>Personal Information</CardTitle>
|
||||
<div className="group relative overflow-hidden w-fit cursor-pointer" onClick={() => {setAddBalanceModal(true)}}>
|
||||
<div className="flex items-center space-x-2 border-y border-l border-[#6d9e37] rounded-full relative z-10 transition-all duration-300">
|
||||
<p className="text-lg font-bold text-center px-2 group-hover:text-white">Balance: {formattedBalance}</p>
|
||||
<Button title="Add Balance" className="text-lg rounded-full group-hover:bg-[#262626] z-10" variant="outline" size="sm">+</Button>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-[#6d9e37] translate-x-full group-hover:translate-x-0 transition-transform duration-300 ease-in-out z-0 rounded-full"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<CardDescription>
|
||||
Update your personal information and avatar.
|
||||
</CardDescription>
|
||||
@@ -104,14 +251,13 @@ export default function ProfilePage() {
|
||||
<AvatarImage src={userData.session_data?.user_avatar} />
|
||||
<AvatarFallback>JP</AvatarFallback>
|
||||
</Avatar>
|
||||
<UpdateAvatar />
|
||||
|
||||
{typedSessionData?.id && <AvatarUpload userId={typedSessionData.id} />}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="firstName">Full Name</Label>
|
||||
<Input id="firstName" defaultValue={userData.session_data?.user_name || 'Jhon'} />
|
||||
<Input id="firstName" defaultValue={userData.session_data?.user_name || ''} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">Phone</Label>
|
||||
@@ -128,78 +274,239 @@ export default function ProfilePage() {
|
||||
</Card>
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Billing Information</CardTitle>
|
||||
<div className="flex flex-row justify-between">
|
||||
<CardTitle>Billing Information</CardTitle>
|
||||
<a href="/profile/billing-info" className="hover:bg-[#6d9e37] hover:text-white transtion duration-500 py-1 px-2 rounded">View All</a>
|
||||
</div>
|
||||
|
||||
<CardDescription>
|
||||
View your billing history.
|
||||
</CardDescription>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Invoice ID</th>
|
||||
<th className="text-left">Date</th>
|
||||
<th className="text-left">Order ID</th>
|
||||
<th className="text-center">Invoice Date</th>
|
||||
<th className="text-left">Description</th>
|
||||
<th className="text-right">Amount</th>
|
||||
<th className="text-center">Amount</th>
|
||||
<th className="text-center">Status</th>
|
||||
<th className="text-center">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
invoiceList.map((invoice) => (
|
||||
<tr key={invoice.id}>
|
||||
<td>{invoice.invoice_id}</td>
|
||||
<td>{invoice.date}</td>
|
||||
<td>{invoice.description}</td>
|
||||
<td className="text-right">{invoice.amount}</td>
|
||||
<td className="text-center"><a href="">Print</a></td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
{
|
||||
invoiceList.slice(0, 5).map((invoice, index) => (
|
||||
<tr key={index} className="">
|
||||
<td>{invoice.billing_id}</td>
|
||||
<td className="text-center">{invoice?.created_at.split(' ')[0]}</td>
|
||||
<td className=""><p className="line-clamp-1">{invoice.service}</p></td>
|
||||
<td className="text-center">{invoice.amount}</td>
|
||||
<td className={`text-center text-sm rounded-full h-fit ${invoice.status === 'pending' ? 'text-yellow-500' : invoice.status === 'completed' ? 'text-green-500' : 'text-red-500'}`}>{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}</td>
|
||||
<td className="text-center flex justify-center items-center gap-2 p-2">
|
||||
<button onClick={() => handleViewItems(invoice)}>
|
||||
<Eye />
|
||||
</button>
|
||||
<PDFDownloadLink
|
||||
title="Download PDF"
|
||||
document={<InvoicePDF data={invoice} />}
|
||||
fileName={`invoice_${invoice.billing_id}.pdf`}
|
||||
className="text-[#6d9e37] hover:text-green-600"
|
||||
>
|
||||
{({ loading }) => (loading ? '...' : <Download className="w-5 h-5" />)}
|
||||
</PDFDownloadLink>
|
||||
{
|
||||
invoice.status !== 'completed' ? (
|
||||
<Button onClick={() => {handlePanelBuyNow(invoice)}} variant="outline" size="sm">
|
||||
{
|
||||
invoice.status === 'pending' ? 'Pay' : invoice.status === 'failed' ? 'Retry' : ''
|
||||
}
|
||||
</Button>
|
||||
) : ''
|
||||
}
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-6 lg:max-w-md">
|
||||
<Card>
|
||||
<div className="flex-1 space-y-6 lg:max-w-xl">
|
||||
<Card className="">
|
||||
<CardHeader>
|
||||
<CardTitle>Security</CardTitle>
|
||||
<CardDescription>
|
||||
Update your password and security settings.
|
||||
</CardDescription>
|
||||
<div className="flex flex-row justify-between">
|
||||
<CardTitle>My Services</CardTitle>
|
||||
{/* <a href="/profile/billing-info" className="hover:bg-[#6d9e37] hover:text-white transtion duration-500 py-1 px-2 rounded">View All</a> */}
|
||||
</div>
|
||||
|
||||
<CardDescription>
|
||||
View your all Services.
|
||||
</CardDescription>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">Order ID</th>
|
||||
<th className="text-center">Date</th>
|
||||
<th className="text-left">Description</th>
|
||||
<th className="text-center">Status</th>
|
||||
{/* <th className="text-center">Action</th> */}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
invoiceList.slice(0, 5).map((invoice, index) => (
|
||||
<tr key={index} className="">
|
||||
<td>{invoice.billing_id}</td>
|
||||
<td className="text-center">{invoice?.created_at.split(' ')[0]}</td>
|
||||
<td className=""><p className="line-clamp-1">{invoice.service}</p></td>
|
||||
<td className={`text-center text-sm rounded-full h-fit ${invoice.service_status === '0' ? 'text-yellow-500' : invoice.service_status === '1' ? 'text-green-500' : 'text-red-500'}`}>{invoice.service_status === '1' ? 'Active' : invoice.service_status === '0' ? 'Unactive' : ''}</td>
|
||||
</tr>
|
||||
))
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">Current password</Label>
|
||||
<Input id="currentPassword" type="password" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">New password</Label>
|
||||
<Input id="newPassword" type="password" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm password</Label>
|
||||
<Input id="confirmPassword" type="password" />
|
||||
</div>
|
||||
<Button className="mt-4">Update password</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<PasswordUpdateCard userId={userData?.session_data?.id} onLogout={sessionLogOut} />
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Danger Zone</CardTitle>
|
||||
<CardDescription>
|
||||
These actions are irreversible. Proceed with caution.
|
||||
</CardDescription>
|
||||
<CardDescription>These actions are irreversible. Proceed with caution.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Button className="bg-red-500 hover:bg-red-600">Delete account</Button>
|
||||
<Button onClick={sessionLogOut} className="bg-red-500 hover:bg-red-600">Logout</Button>
|
||||
<Button className="bg-red-500 hover:bg-red-600">Delete account</Button>
|
||||
<Button variant="outline">Export data</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="">Billing Details</DialogTitle>
|
||||
<DialogDescription className="">Review the details for Billing ID: <span className="font-bold">{selectedData?.billing_id}</span></DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-4 text-sm text-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Service:</span>
|
||||
<span>{selectedData?.service}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">cycle:</span>
|
||||
<span className="capitalize">{selectedData?.cycle}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Amount:</span>
|
||||
<span>₹{selectedData?.amount}</span>
|
||||
</div>
|
||||
{/* <div className="flex justify-between">
|
||||
<span className="font-bold">User:</span>
|
||||
<span>{selectedData?.user}</span>
|
||||
</div> */}
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Status:</span>
|
||||
<span className={`px-2 py-0.5 rounded text-white text-xs ${selectedData?.status === 'pending' ? 'bg-yellow-500' : selectedData?.status === 'completed' ? 'bg-green-500' : 'bg-red-500'}`}>
|
||||
{selectedData?.status}
|
||||
</span>
|
||||
</div>
|
||||
<hr className="my-2 border-gray-200" />
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Created At:</span>
|
||||
<span>{localizeTime(selectedData?.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Updated At:</span>
|
||||
<span>{selectedData?.updated_at ? localizeTime(selectedData.updated_at) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-bold">Silicon ID:</span>
|
||||
<span>{selectedData?.siliconId}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
{/* Optional: Add action buttons here */}
|
||||
{/* <Button onClick={() => setDialogOpen(false)}>Close</Button> */}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Add balance modal */}
|
||||
<Dialog open={addBalanceModal} onOpenChange={setAddBalanceModal}>
|
||||
<DialogContent className="max-w-md sm:rounded-2xl p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="">Add Balance</DialogTitle>
|
||||
<DialogDescription className="">Choose an amount or enter to add to your balance</DialogDescription>
|
||||
{formError && <p className="text-red-600 text-sm mt-2">{formError}</p>}
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-3 flex-wrap mb-4">
|
||||
{["INR", "USD"].map((currency) => (
|
||||
<Button
|
||||
key={currency}
|
||||
variant={selectedCurrency === currency ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setSelectedCurrency(currency);
|
||||
setSelectedAmount('');
|
||||
setCustomAmount('');
|
||||
setFormError('');
|
||||
}}
|
||||
>
|
||||
{currency}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
{balanceData
|
||||
.find((item) => item.currency === selectedCurrency)
|
||||
?.balanceList.map((amount) => (
|
||||
<Button
|
||||
key={amount}
|
||||
variant={customAmount === amount ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setCustomAmount(amount);
|
||||
setSelectedAmount(amount);
|
||||
}}
|
||||
>
|
||||
{selectedCurrency === "INR" ? `₹${amount}` : `$${amount}`}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 border-b ${
|
||||
customAmount ? "border-[#6d9e37]" : "border-gray-700"
|
||||
} p-2 rounded-md text-[#6d9e37] font-bold outline-none mt-4 w-full`}
|
||||
>
|
||||
<span className="text-sm whitespace-nowrap">You’ve selected</span>
|
||||
<span className="text-lg">
|
||||
{selectedCurrency === "INR" ? "₹" : "$"}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Amount"
|
||||
className="w-20 bg-transparent outline-none text-[#6d9e37] text-lg font-bold text-center"
|
||||
value={customAmount}
|
||||
onChange={(e) => {
|
||||
setCustomAmount(e.target.value);
|
||||
setSelectedAmount(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm whitespace-nowrap">to top up your balance.</span>
|
||||
</div>
|
||||
<DialogFooter className="mt-6 flex justify-between w-full">
|
||||
<Button variant="outline" onClick={() => setAddBalanceModal(false)} className="w-full"> Cancel </Button>
|
||||
<Button disabled={!customAmount || balanceApiloading} onClick={handleNext} className="w-full">{balanceApiloading ? "Processing..." : "Next"}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,14 @@ export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "default" | "outline";
|
||||
size?: "default" | "sm" | "lg";
|
||||
type?: "submit" | "button";
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = "default", type = '', size = "default", ...props }, ref) => {
|
||||
({ className, variant = "default", type, size = "default", ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6d9e37] focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-neutral-900",
|
||||
{
|
||||
|
||||
80
src/components/ui/loader.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
const Loader = () => {
|
||||
// CSS styles object
|
||||
const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
width: '100%',
|
||||
},
|
||||
loader: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: '16px',
|
||||
},
|
||||
dot: {
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
margin: '0 4px',
|
||||
backgroundColor: '#6d9e37',
|
||||
borderRadius: '50%',
|
||||
display: 'inline-block',
|
||||
animation: 'bounce 1.4s infinite ease-in-out both',
|
||||
},
|
||||
dot1: {
|
||||
animationDelay: '-0.32s',
|
||||
},
|
||||
dot2: {
|
||||
animationDelay: '-0.16s',
|
||||
},
|
||||
text: {
|
||||
color: '#6d9e37',
|
||||
fontSize: '1rem',
|
||||
fontWeight: '500',
|
||||
},
|
||||
// Keyframes as a string to be injected
|
||||
keyframes: `
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1.0);
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Only run on client-side where document is available
|
||||
if (typeof document !== 'undefined') {
|
||||
const styleSheet = document.styleSheets[0];
|
||||
try {
|
||||
styleSheet.insertRule(styles.keyframes, styleSheet.cssRules.length);
|
||||
} catch (e) {
|
||||
// Fallback for browsers that might not support this
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.innerHTML = styles.keyframes;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
}
|
||||
}, []); // Empty dependency array means this runs once on mount
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.loader}>
|
||||
<div style={{ ...styles.dot, ...styles.dot1 }}></div>
|
||||
<div style={{ ...styles.dot, ...styles.dot2 }}></div>
|
||||
<div style={styles.dot}></div>
|
||||
</div>
|
||||
<p style={styles.text}>Loading...</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loader;
|
||||
137
src/components/ui/select.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-neutral-600 bg-neutral-800 px-3 py-2 text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:ring-offset-2 focus:ring-offset-neutral-900",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef(
|
||||
({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-neutral-600 bg-neutral-800 shadow-md animate-in fade-in-80",
|
||||
position === "popper" && "translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef(
|
||||
({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"focus:bg-neutral-700 focus:text-white",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef(
|
||||
({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-neutral-600", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
const ChevronDownIcon = React.forwardRef((props, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3.13523 6.15803C3.3241 5.95657 3.64052 5.94637 3.84197 6.13523L7.5 9.56464L11.158 6.13523C11.3595 5.94637 11.6759 5.95657 11.8648 6.15803C12.0536 6.35949 12.0434 6.67591 11.842 6.86477L7.84197 10.6148C7.64964 10.7951 7.35036 10.7951 7.15803 10.6148L3.15803 6.86477C2.95657 6.67591 2.94637 6.35949 3.13523 6.15803Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
));
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
};
|
||||
|
||||
export {
|
||||
Select as SelectProps,
|
||||
SelectGroup as SelectGroupProps,
|
||||
SelectValue as SelectValueProps,
|
||||
SelectTrigger as SelectTriggerProps,
|
||||
SelectContent as SelectContentProps,
|
||||
SelectLabel as SelectLabelProps,
|
||||
SelectItem as SelectItemProps,
|
||||
SelectSeparator as SelectSeparatorProps,
|
||||
};
|
||||
@@ -1,130 +0,0 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-neutral-600 bg-neutral-800 px-3 py-2 text-sm",
|
||||
"focus:outline-none focus:ring-2 focus:ring-[#6d9e37] focus:ring-offset-2 focus:ring-offset-neutral-900",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border border-neutral-600 bg-neutral-800 shadow-md animate-in fade-in-80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport className="p-1">
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
|
||||
"focus:bg-neutral-700 focus:text-white",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-neutral-600", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
// ChevronDownIcon component
|
||||
const ChevronDownIcon = React.forwardRef<
|
||||
SVGSVGElement,
|
||||
React.SVGProps<SVGSVGElement>
|
||||
>((props, ref) => (
|
||||
<svg
|
||||
ref={ref}
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3.13523 6.15803C3.3241 5.95657 3.64052 5.94637 3.84197 6.13523L7.5 9.56464L11.158 6.13523C11.3595 5.94637 11.6759 5.95657 11.8648 6.15803C12.0536 6.35949 12.0434 6.67591 11.842 6.86477L7.84197 10.6148C7.64964 10.7951 7.35036 10.7951 7.15803 10.6148L3.15803 6.86477C2.95657 6.67591 2.94637 6.35949 3.13523 6.15803Z"
|
||||
fill="currentColor"
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
));
|
||||
ChevronDownIcon.displayName = "ChevronDownIcon";
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
};
|
||||
29
src/components/ui/switch.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
|
||||
export const Switch = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
id,
|
||||
className = "",
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
id={id}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#6d9e37] ${
|
||||
checked ? "bg-[#6d9e37]" : "bg-gray-200"
|
||||
} ${className}`}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
checked ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -1,33 +1,48 @@
|
||||
import * as React from "react";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export interface CustomTabsProps {
|
||||
// Main Tabs component
|
||||
const Tabs = RadixTabs.Root;
|
||||
|
||||
// Subcomponents
|
||||
const TabsList = RadixTabs.List;
|
||||
const TabsTrigger = RadixTabs.Trigger;
|
||||
const TabsContent = RadixTabs.Content;
|
||||
|
||||
// CustomTabs component (your existing implementation)
|
||||
interface CustomTabsProps {
|
||||
tabs: { label: string; value: string; content: React.ReactNode }[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CustomTabs: React.FC<CustomTabsProps> = ({ tabs, className }) => {
|
||||
return (
|
||||
<Tabs.Root defaultValue={tabs[0]?.value} className={cn("w-full", className)}>
|
||||
<Tabs.List className="flex border-b border-neutral-600 bg-neutral-800">
|
||||
<Tabs defaultValue={tabs[0]?.value} className={cn("w-full", className)}>
|
||||
<TabsList className="flex border-b border-neutral-600 bg-neutral-800">
|
||||
{tabs.map((tab) => (
|
||||
<Tabs.Trigger
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="px-4 py-2 text-sm text-neutral-400 hover:text-white focus-visible:ring-2 focus-visible:ring-[#6d9e37]"
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Trigger>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<Tabs.Content key={tab.value} value={tab.value} className="p-4 text-neutral-300">
|
||||
<TabsContent key={tab.value} value={tab.value} className="p-4 text-neutral-300">
|
||||
{tab.content}
|
||||
</Tabs.Content>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs.Root>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export { CustomTabs };
|
||||
export {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
CustomTabs
|
||||
};
|
||||
77
src/components/ui/toast.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
export const Toast = ({ message, type, onDismiss, duration = 3000 }) => {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss();
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [duration, onDismiss]);
|
||||
|
||||
const bgColor = {
|
||||
success: 'bg-green-500',
|
||||
error: 'bg-red-500',
|
||||
warning: 'bg-yellow-500',
|
||||
info: 'bg-blue-500',
|
||||
}[type] || 'bg-gray-500';
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-4 right-4 ${bgColor} text-white px-4 py-2 rounded-md shadow-lg flex items-center`}>
|
||||
<span>{message}</span>
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="ml-2 text-white hover:text-gray-200 font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToastProvider = ({ children }) => {
|
||||
const [toasts, setToasts] = React.useState([]);
|
||||
|
||||
const showToast = (message, options = {}) => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, ...options }]);
|
||||
};
|
||||
|
||||
const dismissToast = (id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
duration={toast.duration}
|
||||
onDismiss={() => dismissToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const [toasts, setToasts] = React.useState([]);
|
||||
|
||||
const showToast = (message, options = {}) => {
|
||||
const id = Date.now();
|
||||
setToasts((prev) => [...prev, { id, message, ...options }]);
|
||||
|
||||
setTimeout(() => {
|
||||
dismissToast(id);
|
||||
}, options.duration || 3000);
|
||||
};
|
||||
|
||||
const dismissToast = (id) => {
|
||||
setToasts((prev) => prev.filter((toast) => toast.id !== id));
|
||||
};
|
||||
|
||||
return { showToast, dismissToast };
|
||||
};
|
||||
8
src/lib/CookieValues.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// CookieValues.js
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
export const token = Cookies.get('token') || null;
|
||||
export const user_name = Cookies.get('user_name') || null;
|
||||
export const pb_id = Cookies.get('pb_id') || null;
|
||||
export const siliconId = Cookies.get('siliconId') || null;
|
||||
export const isLogin = Cookies.get('isLogin') || null;
|
||||
232
src/lib/InvoicePDF.jsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import React from 'react';
|
||||
import { Page, Text, View, Document, StyleSheet, Image, Font } from '@react-pdf/renderer';
|
||||
import {localizeTime} from "../lib/localizeTime";
|
||||
import { IndianRupee } from "lucide-react";
|
||||
import NotoSans from "../lib/fonts/static/NotoSans_Condensed-Bold.ttf";
|
||||
Font.register({
|
||||
family: "NotoSans",
|
||||
src: NotoSans,
|
||||
});
|
||||
// console.log('Fonts', Font)
|
||||
// Create styles
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
display: "flex",
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 40,
|
||||
},
|
||||
currencyValue: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: '#6d9e37',
|
||||
paddingBottom: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
logoContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginBottom: 5
|
||||
},
|
||||
logoImage: {
|
||||
width: 30,
|
||||
height: 30
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#6d9e37'
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 5
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
value: {
|
||||
fontSize: 12
|
||||
},
|
||||
divider: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#EEEEEE',
|
||||
marginVertical: 10
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F5F5F5',
|
||||
padding: 5,
|
||||
marginBottom: 5
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
padding: 5,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#EEEEEE'
|
||||
},
|
||||
col1: {
|
||||
width: '40%',
|
||||
fontSize: '12px'
|
||||
},
|
||||
col2: {
|
||||
width: '30%',
|
||||
textAlign: 'right',
|
||||
fontSize: '12px'
|
||||
},
|
||||
col3: {
|
||||
width: '30%',
|
||||
textAlign: 'right',
|
||||
fontSize: '12px'
|
||||
},
|
||||
total: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 10
|
||||
},
|
||||
totalText: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 30,
|
||||
left: 40,
|
||||
right: 40,
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
color: '#999999'
|
||||
},
|
||||
status: {
|
||||
padding: 3,
|
||||
borderRadius: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Base64 encoded SVG logo (replace with your actual logo)
|
||||
const SILICONPIN_LOGO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAAA7EAAAOxAGVKw4bAAABgElEQVR4nO3bsW0UURRA0TfYLThEDreatUSGC7BdBi4D0QAiWiQ3g0OTQgvsONiICv4J7pF+PE//jmaiN/vMDpzHWWyfedhnzqvv4sPqi8j/CoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmE1Ylvn0eT7+PMzNyhmOv+bv6cf8vlo5xMxsi59/8WW+zjZPi6f4Ns/LZ+iTpSkIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYbWZ/WD3E3N/dzuFl6cLOvB7/zPfT28zqlZ3Zz8BZvsV1eTH3f6vv4nqULSrDNovvo38IpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmDeAd5vHuV+dwDGAAAAAElFTkSuQmCC';
|
||||
|
||||
const InvoicePDF = ({ data }) => {
|
||||
const statusColor = data.status === 'completed' ? '#10B981' : '#EF4444';
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>INVOICE</Text>
|
||||
<Text style={styles.label}>Invoice #: {data.billing_id}</Text>
|
||||
<Text style={styles.label}>Date: {localizeTime(data.created_at)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Bill To */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<View style={{display: 'flex', flexDirection: 'row', gap: '10px'}}>
|
||||
<Text style={styles.label}>BILL TO:</Text>
|
||||
<Text style={styles.value}>{data.name}</Text>
|
||||
</View>
|
||||
{
|
||||
data.status === 'completed' && (
|
||||
<View style={{display: 'flex', flexDirection: 'row', gap: '10px'}}>
|
||||
<Text style={styles.label}>PAID ON:</Text>
|
||||
<Text style={styles.value}>
|
||||
{data.payment_date}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
<View style={{display: 'flex', flexDirection: 'row', gap: '10px'}}>
|
||||
<Text style={styles.label}>STATUS:</Text>
|
||||
<Text style={[styles.value, { color: statusColor }]}>
|
||||
{data.status.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Items Table */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={styles.col1}>DESCRIPTION</Text>
|
||||
<Text style={styles.col2}>CYCLE</Text>
|
||||
<Text style={styles.col3}>AMOUNT</Text>
|
||||
</View>
|
||||
<View style={styles.tableRow}>
|
||||
<Text style={styles.col1}>{data.service}</Text>
|
||||
<Text style={styles.col2}>{data.cycle}</Text>
|
||||
<Text style={{fontFamily: 'NotoSans', ...styles.col3}}>
|
||||
{new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
}).format(parseFloat(data.amount))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Total */}
|
||||
<View style={styles.total}>
|
||||
<View style={{width: '30%'}}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>SUBTOTAL:</Text>
|
||||
<Text style={{fontFamily: 'NotoSans', ...styles.value}}>
|
||||
{new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
}).format(parseFloat(data.amount))}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>TOTAL:</Text>
|
||||
<Text style={{fontFamily: 'NotoSans', ...styles.totalText}}>
|
||||
{new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
}).format(parseFloat(data.amount))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.header} />
|
||||
<View style={{}}>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
style={styles.logoImage}
|
||||
src={SILICONPIN_LOGO}
|
||||
/>
|
||||
<Text style={{fontSize: '12px', fontWeight: 'bold', color: '#6d9e37'}}>SiliconPin, Habra, W.B. 743271, India, contact@siliconpin.com</Text>
|
||||
</View>
|
||||
{/* <Text style={styles.value}></Text>
|
||||
<Text style={styles.value}></Text>
|
||||
<Text style={styles.value}></Text> */}
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
{/* <View style={styles.footer}>
|
||||
<Text>Thank you for your business!</Text>
|
||||
<Text>Please make payments payable to SiliconPin</Text>
|
||||
</View> */}
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoicePDF;
|
||||
219
src/lib/InvoicePDF_V1.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React from 'react';
|
||||
import { Page, Text, View, Document, StyleSheet, Image, Font } from '@react-pdf/renderer';
|
||||
import {localizeTime} from "../lib/localizeTime";
|
||||
import { IndianRupee } from "lucide-react";
|
||||
import NotoSans from "../lib/fonts/static/NotoSans_Condensed-Bold.ttf";
|
||||
Font.register({
|
||||
family: "NotoSans",
|
||||
src: NotoSans,
|
||||
});
|
||||
// console.log('Fonts', Font)
|
||||
// Create styles
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
display: "flex",
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#FFFFFF',
|
||||
padding: 40,
|
||||
},
|
||||
currencyValue: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 20,
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: '#6d9e37',
|
||||
paddingBottom: 10
|
||||
},
|
||||
logoContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginBottom: 5
|
||||
},
|
||||
logoImage: {
|
||||
width: 30,
|
||||
height: 30
|
||||
},
|
||||
title: {
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
color: '#6d9e37'
|
||||
},
|
||||
section: {
|
||||
marginBottom: 20
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 5
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
value: {
|
||||
fontSize: 12
|
||||
},
|
||||
divider: {
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#EEEEEE',
|
||||
marginVertical: 10
|
||||
},
|
||||
tableHeader: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#F5F5F5',
|
||||
padding: 5,
|
||||
marginBottom: 5
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: 'row',
|
||||
padding: 5,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#EEEEEE'
|
||||
},
|
||||
col1: {
|
||||
width: '40%'
|
||||
},
|
||||
col2: {
|
||||
width: '30%',
|
||||
textAlign: 'right'
|
||||
},
|
||||
col3: {
|
||||
width: '30%',
|
||||
textAlign: 'right'
|
||||
},
|
||||
total: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
marginTop: 10
|
||||
},
|
||||
totalText: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold'
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 30,
|
||||
left: 40,
|
||||
right: 40,
|
||||
textAlign: 'center',
|
||||
fontSize: 10,
|
||||
color: '#999999'
|
||||
},
|
||||
status: {
|
||||
padding: 3,
|
||||
borderRadius: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Base64 encoded SVG logo (replace with your actual logo)
|
||||
const SILICONPIN_LOGO = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAACXBIWXMAAA7EAAAOxAGVKw4bAAABgElEQVR4nO3bsW0UURRA0TfYLThEDreatUSGC7BdBi4D0QAiWiQ3g0OTQgvsONiICv4J7pF+PE//jmaiN/vMDpzHWWyfedhnzqvv4sPqi8j/CoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmIJgCoIpCKYgmE1Ylvn0eT7+PMzNyhmOv+bv6cf8vlo5xMxsi59/8WW+zjZPi6f4Ns/LZ+iTpSkIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYbWZ/WD3E3N/dzuFl6cLOvB7/zPfT28zqlZ3Zz8BZvsV1eTH3f6vv4nqULSrDNovvo38IpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmAKgikIpiCYgmDeAd5vHuV+dwDGAAAAAElFTkSuQmCC';
|
||||
|
||||
const InvoicePDF = ({ data }) => {
|
||||
const statusColor = data.status === 'completed' ? '#10B981' : '#EF4444';
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<View>
|
||||
<Text style={styles.title}>INVOICE</Text>
|
||||
<Text style={styles.label}>Invoice #: {data.billing_id}</Text>
|
||||
<Text style={styles.label}>Date: {localizeTime(data.created_at)}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<View style={styles.logoContainer}>
|
||||
<Image
|
||||
style={styles.logoImage}
|
||||
src={SILICONPIN_LOGO}
|
||||
/>
|
||||
<Text style={styles.title}>SiliconPin</Text>
|
||||
</View>
|
||||
<Text style={styles.value}>121 Lalbari, GourBongo Road</Text>
|
||||
<Text style={styles.value}>Habra, W.B. 743271, India</Text>
|
||||
<Text style={styles.value}>contact@siliconpin.com</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Bill To */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<View>
|
||||
<Text style={styles.label}>BILL TO:</Text>
|
||||
<Text style={styles.value}>{data.name}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.label}>STATUS:</Text>
|
||||
<Text style={[styles.value, { color: statusColor }]}>
|
||||
{data.status.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Items Table */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.tableHeader}>
|
||||
<Text style={styles.col1}>DESCRIPTION</Text>
|
||||
<Text style={styles.col2}>cycle</Text>
|
||||
<Text style={styles.col3}>AMOUNT</Text>
|
||||
</View>
|
||||
<View style={styles.tableRow}>
|
||||
<Text style={styles.col1}>{data.service}</Text>
|
||||
<Text style={styles.col2}>{data.cycle}</Text>
|
||||
<Text style={{fontFamily: 'NotoSans', ...styles.col3}}>
|
||||
{new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
}).format(parseFloat(data.amount))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* Total */}
|
||||
<View style={styles.total}>
|
||||
<View style={{width: '30%'}}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>SUBTOTAL:</Text>
|
||||
<Text style={{fontFamily: 'NotoSans', ...styles.value}}>
|
||||
{new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
}).format(parseFloat(data.amount))}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.label}>TOTAL:</Text>
|
||||
<Text style={{fontFamily: 'NotoSans', ...styles.totalText}}>
|
||||
{new Intl.NumberFormat('en-IN', {
|
||||
style: 'currency',
|
||||
currency: 'INR',
|
||||
}).format(parseFloat(data.amount))}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<View style={styles.footer}>
|
||||
<Text>Thank you for your business!</Text>
|
||||
<Text>Please make payments payable to SiliconPin</Text>
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoicePDF;
|
||||
BIN
src/lib/fonts/NotoSans-Italic-VariableFont_wdth,wght.ttf
Normal file
BIN
src/lib/fonts/NotoSans-VariableFont_wdth,wght.ttf
Normal file
93
src/lib/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
136
src/lib/fonts/README.txt
Normal file
@@ -0,0 +1,136 @@
|
||||
Noto Sans Variable Font
|
||||
=======================
|
||||
|
||||
This download contains Noto Sans as both variable fonts and static fonts.
|
||||
|
||||
Noto Sans is a variable font with these axes:
|
||||
wdth
|
||||
wght
|
||||
|
||||
This means all the styles are contained in these files:
|
||||
NotoSans-VariableFont_wdth,wght.ttf
|
||||
NotoSans-Italic-VariableFont_wdth,wght.ttf
|
||||
|
||||
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||
in those cases you can use the static font files for Noto Sans:
|
||||
static/NotoSans_ExtraCondensed-Thin.ttf
|
||||
static/NotoSans_ExtraCondensed-ExtraLight.ttf
|
||||
static/NotoSans_ExtraCondensed-Light.ttf
|
||||
static/NotoSans_ExtraCondensed-Regular.ttf
|
||||
static/NotoSans_ExtraCondensed-Medium.ttf
|
||||
static/NotoSans_ExtraCondensed-SemiBold.ttf
|
||||
static/NotoSans_ExtraCondensed-Bold.ttf
|
||||
static/NotoSans_ExtraCondensed-ExtraBold.ttf
|
||||
static/NotoSans_ExtraCondensed-Black.ttf
|
||||
static/NotoSans_Condensed-Thin.ttf
|
||||
static/NotoSans_Condensed-ExtraLight.ttf
|
||||
static/NotoSans_Condensed-Light.ttf
|
||||
static/NotoSans_Condensed-Regular.ttf
|
||||
static/NotoSans_Condensed-Medium.ttf
|
||||
static/NotoSans_Condensed-SemiBold.ttf
|
||||
static/NotoSans_Condensed-Bold.ttf
|
||||
static/NotoSans_Condensed-ExtraBold.ttf
|
||||
static/NotoSans_Condensed-Black.ttf
|
||||
static/NotoSans_SemiCondensed-Thin.ttf
|
||||
static/NotoSans_SemiCondensed-ExtraLight.ttf
|
||||
static/NotoSans_SemiCondensed-Light.ttf
|
||||
static/NotoSans_SemiCondensed-Regular.ttf
|
||||
static/NotoSans_SemiCondensed-Medium.ttf
|
||||
static/NotoSans_SemiCondensed-SemiBold.ttf
|
||||
static/NotoSans_SemiCondensed-Bold.ttf
|
||||
static/NotoSans_SemiCondensed-ExtraBold.ttf
|
||||
static/NotoSans_SemiCondensed-Black.ttf
|
||||
static/NotoSans-Thin.ttf
|
||||
static/NotoSans-ExtraLight.ttf
|
||||
static/NotoSans-Light.ttf
|
||||
static/NotoSans-Regular.ttf
|
||||
static/NotoSans-Medium.ttf
|
||||
static/NotoSans-SemiBold.ttf
|
||||
static/NotoSans-Bold.ttf
|
||||
static/NotoSans-ExtraBold.ttf
|
||||
static/NotoSans-Black.ttf
|
||||
static/NotoSans_ExtraCondensed-ThinItalic.ttf
|
||||
static/NotoSans_ExtraCondensed-ExtraLightItalic.ttf
|
||||
static/NotoSans_ExtraCondensed-LightItalic.ttf
|
||||
static/NotoSans_ExtraCondensed-Italic.ttf
|
||||
static/NotoSans_ExtraCondensed-MediumItalic.ttf
|
||||
static/NotoSans_ExtraCondensed-SemiBoldItalic.ttf
|
||||
static/NotoSans_ExtraCondensed-BoldItalic.ttf
|
||||
static/NotoSans_ExtraCondensed-ExtraBoldItalic.ttf
|
||||
static/NotoSans_ExtraCondensed-BlackItalic.ttf
|
||||
static/NotoSans_Condensed-ThinItalic.ttf
|
||||
static/NotoSans_Condensed-ExtraLightItalic.ttf
|
||||
static/NotoSans_Condensed-LightItalic.ttf
|
||||
static/NotoSans_Condensed-Italic.ttf
|
||||
static/NotoSans_Condensed-MediumItalic.ttf
|
||||
static/NotoSans_Condensed-SemiBoldItalic.ttf
|
||||
static/NotoSans_Condensed-BoldItalic.ttf
|
||||
static/NotoSans_Condensed-ExtraBoldItalic.ttf
|
||||
static/NotoSans_Condensed-BlackItalic.ttf
|
||||
static/NotoSans_SemiCondensed-ThinItalic.ttf
|
||||
static/NotoSans_SemiCondensed-ExtraLightItalic.ttf
|
||||
static/NotoSans_SemiCondensed-LightItalic.ttf
|
||||
static/NotoSans_SemiCondensed-Italic.ttf
|
||||
static/NotoSans_SemiCondensed-MediumItalic.ttf
|
||||
static/NotoSans_SemiCondensed-SemiBoldItalic.ttf
|
||||
static/NotoSans_SemiCondensed-BoldItalic.ttf
|
||||
static/NotoSans_SemiCondensed-ExtraBoldItalic.ttf
|
||||
static/NotoSans_SemiCondensed-BlackItalic.ttf
|
||||
static/NotoSans-ThinItalic.ttf
|
||||
static/NotoSans-ExtraLightItalic.ttf
|
||||
static/NotoSans-LightItalic.ttf
|
||||
static/NotoSans-Italic.ttf
|
||||
static/NotoSans-MediumItalic.ttf
|
||||
static/NotoSans-SemiBoldItalic.ttf
|
||||
static/NotoSans-BoldItalic.ttf
|
||||
static/NotoSans-ExtraBoldItalic.ttf
|
||||
static/NotoSans-BlackItalic.ttf
|
||||
|
||||
Get started
|
||||
-----------
|
||||
|
||||
1. Install the font files you want to use
|
||||
|
||||
2. Use your app's font picker to view the font family and all the
|
||||
available styles
|
||||
|
||||
Learn more about variable fonts
|
||||
-------------------------------
|
||||
|
||||
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||
https://variablefonts.typenetwork.com
|
||||
https://medium.com/variable-fonts
|
||||
|
||||
In desktop apps
|
||||
|
||||
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||
|
||||
Online
|
||||
|
||||
https://developers.google.com/fonts/docs/getting_started
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||
|
||||
Installing fonts
|
||||
|
||||
MacOS: https://support.apple.com/en-us/HT201749
|
||||
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||
|
||||
Android Apps
|
||||
|
||||
https://developers.google.com/fonts/docs/android
|
||||
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||
|
||||
License
|
||||
-------
|
||||
Please read the full license text (OFL.txt) to understand the permissions,
|
||||
restrictions and requirements for usage, redistribution, and modification.
|
||||
|
||||
You can use them in your products & projects – print or digital,
|
||||
commercial or otherwise.
|
||||
|
||||
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||
license for all details.
|
||||