diff --git a/biome.json b/biome.json index a0f9afd..f87a2ec 100644 --- a/biome.json +++ b/biome.json @@ -29,8 +29,7 @@ "noDelete": "warn" }, "complexity": { - "noForEach": "warn", - "noImportantStyles": "off" + "noForEach": "warn" }, "suspicious": { "noConsole": { "level": "error", "options": { "allow": ["error"] } } diff --git a/client/components/razorpayCheckout/index.js b/client/components/razorpayCheckout/index.js new file mode 100644 index 0000000..81cde1c --- /dev/null +++ b/client/components/razorpayCheckout/index.js @@ -0,0 +1,205 @@ +/** + * Razorpay Checkout Component + * Handles web-based plugin purchases via Razorpay payment gateway + */ + +import alert from 'components/dialogs/alert'; +import Ref from 'html-tag-js/ref'; + +// Load Razorpay script dynamically +let razorpayLoaded = false; +function loadRazorpayScript() { + return new Promise((resolve, reject) => { + if (razorpayLoaded) { + resolve(); + return; + } + + const script = document.createElement('script'); + script.src = 'https://checkout.razorpay.com/v1/checkout.js'; + script.async = true; + script.onload = () => { + razorpayLoaded = true; + resolve(); + }; + script.onerror = () => reject(new Error('Failed to load Razorpay script')); + document.head.appendChild(script); + }); +} + +/** + * Check if user owns a plugin + * @param {string} pluginId + * @returns {Promise} + */ +export async function checkPluginOwnership(pluginId) { + try { + const res = await fetch(`/api/razorpay/check-ownership/${pluginId}`); + const data = await res.json(); + return data.owned === true; + } catch (error) { + console.error('Failed to check plugin ownership:', error); + return false; + } +} + +/** + * Razorpay checkout configuration + */ +const RAZORPAY_CONFIG = { + theme: { + color: '#2563eb', + backdrop_color: 'rgba(15, 23, 42, 0.8)', + }, + branding: { + name: 'Acode Plugin Store', + image: '/logo-512.png', + }, +}; + +/** + * Initiate Razorpay checkout for a plugin + * @param {string} pluginId + * @param {Object} userInfo - User information for prefill + * @param {string} [userInfo.email] - User's email address + * @param {string} [userInfo.name] - User's name + * @param {Function} onSuccess - Callback on successful payment + * @param {Function} [onCancel] - Callback when checkout is cancelled + * @returns {Promise} + */ +export async function initiateCheckout(pluginId, userInfo = {}, onSuccess, onCancel) { + try { + // Load Razorpay script if not already loaded + await loadRazorpayScript(); + + // Create order on server + const orderRes = await fetch('/api/razorpay/create-order', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pluginId }), + }); + + const orderData = await orderRes.json(); + + if (orderData.error) { + alert('ERROR', orderData.error); + return; + } + + const { orderId, amount, currency, keyId, pluginName, userEmail } = orderData; + + // Open Razorpay checkout with customization + const options = { + key: keyId, + amount, + currency, + name: RAZORPAY_CONFIG.branding.name, + description: `Purchase: ${pluginName}`, + image: RAZORPAY_CONFIG.branding.image, + order_id: orderId, + handler: async (response) => { + try { + const verifyRes = await fetch('/api/razorpay/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + razorpay_order_id: response.razorpay_order_id, + razorpay_payment_id: response.razorpay_payment_id, + razorpay_signature: response.razorpay_signature, + pluginId, + }), + }); + + if (!verifyRes.ok) throw new Error('Verification request failed'); + + const verifyData = await verifyRes.json(); + + if (verifyData.success) { + alert('SUCCESS', 'Payment successful! You can now download this plugin.'); + if (onSuccess) onSuccess(); + } else { + alert('ERROR', verifyData.error || 'Payment verification failed'); + } + } catch (error) { + console.error('Verification error:', error); + alert('ERROR', 'Payment may have succeeded but verification failed. Please contact support if charged.'); + } + }, + // Prefill user information (email preferred over contact for web) + prefill: { + email: userInfo.email || userEmail || '', + name: userInfo.name || '', + // Note: contact (phone) is required by Razorpay for Indian regulations + // but email will be shown as primary identifier + }, + // Theme customization + theme: { + color: RAZORPAY_CONFIG.theme.color, + backdrop_color: RAZORPAY_CONFIG.theme.backdrop_color, + }, + // Modal behavior + modal: { + confirm_close: true, // Ask before closing + escape: true, // Allow ESC to close + animation: true, // Enable animations + ondismiss: () => { + if (onCancel) onCancel(); + }, + }, + // Additional checkout preferences + notes: { + pluginId, + source: 'acode_web', + }, + }; + + const rzp = new window.Razorpay(options); + rzp.on('payment.failed', (response) => { + alert('ERROR', `Payment failed: ${response.error.description}`); + }); + rzp.open(); + } catch (error) { + console.error('Checkout error:', error); + alert('ERROR', error.message || 'Failed to initiate checkout'); + } +} + +/** + * Buy Button Component for paid plugins + * @param {Object} props + * @param {string} props.pluginId - Plugin ID + * @param {number} props.price - Plugin price in INR + * @param {Object} [props.user] - Logged in user object + * @param {Function} [props.onPurchaseComplete] - Callback after successful purchase + * @returns {HTMLElement} + */ +export default function BuyButton({ pluginId, price, user, onPurchaseComplete }) { + const buttonRef = Ref(); + const buttonTextRef = Ref(); + + const handleClick = async () => { + buttonRef.el.disabled = true; + buttonTextRef.el.textContent = 'Processing...'; + + const handleSuccess = () => { + buttonTextRef.el.textContent = 'Purchased ✓'; + buttonRef.el.disabled = true; + if (onPurchaseComplete) onPurchaseComplete(); + }; + + const handleCancel = () => { + buttonTextRef.el.textContent = `Buy ₹${price}`; + buttonRef.el.disabled = false; + }; + + const userInfo = user ? { email: user.email, name: user.name } : {}; + await initiateCheckout(pluginId, userInfo, handleSuccess, handleCancel); + }; + + return ( + + ); +} diff --git a/client/pages/FAQs/index.js b/client/pages/FAQs/index.js index a60d99e..a67a7bb 100644 --- a/client/pages/FAQs/index.js +++ b/client/pages/FAQs/index.js @@ -69,13 +69,11 @@ export default async function FAQs({ mode, oldQ, a, qHash }) {
- {isUpdate ? ( - - ) : ( - '' - )} + {isUpdate + ? + : ''}
@@ -123,14 +121,12 @@ export default async function FAQs({ mode, oldQ, a, qHash }) {

- {isAdmin ? ( -

- editFaq(q, ans)} title='Edit this FAQ' className='link icon create' /> - deleteFaq(q)} title='Delete this FAQ' className='link icon delete danger' /> -
- ) : ( - '' - )} + {isAdmin + ?
+ editFaq(q, ans)} title='Edit this FAQ' className='link icon create' /> + deleteFaq(q)} title='Delete this FAQ' className='link icon delete danger' /> +
+ : ''}
); } diff --git a/client/pages/changePassword/index.js b/client/pages/changePassword/index.js index b03e5e7..046a6b9 100644 --- a/client/pages/changePassword/index.js +++ b/client/pages/changePassword/index.js @@ -32,25 +32,23 @@ export default async function changePassword({ mode, redirect }) { loading={(form) => loadingStart(form, errorText, successText)} loadingEnd={(form) => loadingEnd(form, 'Change password')} > - {mode === 'reset' ? ( -
-
- { - email = e.target.value; - }} - type='email' - name='email' - label='Email' - placeholder='e.g. john@gmail.com' - /> - -
- email} /> -
- ) : ( - - )} + {mode === 'reset' + ?
+
+ { + email = e.target.value; + }} + type='email' + name='email' + label='Email' + placeholder='e.g. john@gmail.com' + /> + +
+ email} /> +
+ : } {errorText} {successText} diff --git a/client/pages/plugin/index.js b/client/pages/plugin/index.js index 0b87854..e255583 100644 --- a/client/pages/plugin/index.js +++ b/client/pages/plugin/index.js @@ -7,6 +7,7 @@ import confirm from 'components/dialogs/confirm'; import prompt from 'components/dialogs/prompt'; import Input from 'components/input'; import MonthSelect from 'components/MonthSelect'; +import BuyButton, { checkPluginOwnership } from 'components/razorpayCheckout'; import YearSelect from 'components/YearSelect'; import hilightjs from 'highlight.js'; import Ref from 'html-tag-js/ref'; @@ -64,6 +65,12 @@ export default async function Plugin({ id: pluginId, section = 'description' }) const shouldShowOrders = user && (user.id === userId || user.isAdmin) && !!plugin.price; let canInstall = /android/i.test(navigator.userAgent); + let userOwnsPlugin = false; + + // Check if logged-in user owns this paid plugin (for web purchases) + if (user && price > 0) { + userOwnsPlugin = await checkPluginOwnership(id); + } if (user?.isAdmin && plugin.status !== 'approved') { canInstall = false; @@ -87,6 +94,35 @@ export default async function Plugin({ id: pluginId, section = 'description' }) table.replaceWith(
{table.cloneNode(true)}
); } + function renderPurchaseButton() { + if (userOwnsPlugin) { + return ( +
+ + Purchased +
+ ); + } + if (user) { + return ( + { + window.location.reload(); + }} + /> + ); + } + return ( + + + Login to Purchase + + ); + } + return (
@@ -105,25 +141,21 @@ export default async function Plugin({ id: pluginId, section = 'description' })
v {version} - {+downloads ? ( -
- - {downloads.toLocaleString()} -
- ) : ( -
- New -
- )} + {+downloads + ?
+ + {downloads.toLocaleString()} +
+ :
+ New +
}
- {price ? ( - <> - - {price} - - ) : ( - Free - )} + {price + ? <> + + {price} + + : Free}
{commentCount > 0 && (
changeSection('comments')}> @@ -160,6 +192,8 @@ export default async function Plugin({ id: pluginId, section = 'description' }) )}
+ {/* Payment Section for paid plugins - placed after plugin info */} + {price > 0 && !canInstall &&
{renderPurchaseButton()}
}
@@ -406,13 +440,11 @@ function CommentsContainerAndForm({ plugin, listRef, user, id, userComment }) {
- {commentId ? ( - - ) : ( - '' - )} + {commentId + ? + : ''}
diff --git a/client/pages/plugin/style.scss b/client/pages/plugin/style.scss index 748be66..a88795f 100644 --- a/client/pages/plugin/style.scss +++ b/client/pages/plugin/style.scss @@ -40,6 +40,77 @@ margin: 0 10px; } } + + .purchase-section { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + } + + .owned-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 20px; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border-radius: 8px; + font-weight: 600; + box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); + + .icon { + font-size: 1.2em; + } + } + + .login-to-buy { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%); + color: white; + border-radius: 8px; + font-weight: 600; + text-decoration: none; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); + } + } + + .buy-button { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + color: white; + border: none; + padding: 10px 20px; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); + } + + &:active { + transform: translateY(0); + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; + } } } diff --git a/client/pages/registerUser/index.js b/client/pages/registerUser/index.js index a58ab23..5e5cc9b 100644 --- a/client/pages/registerUser/index.js +++ b/client/pages/registerUser/index.js @@ -53,11 +53,9 @@ export default async function registerUser({ mode, redirect }) { - {mode === 'edit' ? ( - Change password - ) : ( - - )} + {mode === 'edit' + ? Change password + : }
{errorText}
{successText}
diff --git a/client/pages/user/index.js b/client/pages/user/index.js index 07448ab..8c9577a 100644 --- a/client/pages/user/index.js +++ b/client/pages/user/index.js @@ -69,22 +69,18 @@ export default async function User({ userId }) {
- {shouldShowSensitiveInfo ? ( - - |{moment().format('YYYY MMMM')} - - ) : ( - '' - )} + {shouldShowSensitiveInfo + ? + |{moment().format('YYYY MMMM')} + + : ''}
- {isSelf ? ( -
- - Payment method -
- ) : ( - '' - )} + {isSelf + ?
+ + Payment method +
+ : ''}
e.target.dataset.href && Router.loadUrl(e.target.dataset.href)}>