Square Payments
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SASA Membership Portal</title>
|
||||
<!-- Square Web Payments SDK -->
|
||||
<script type="text/javascript" src="https://sandbox.web.squarecdn.com/v1/square.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
||||
import SquarePayment from './SquarePayment';
|
||||
|
||||
interface MembershipSetupProps {
|
||||
onMembershipCreated: () => void;
|
||||
@@ -11,7 +12,9 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
const [selectedTier, setSelectedTier] = useState<MembershipTier | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [step, setStep] = useState<'select' | 'payment' | 'confirm'>('select');
|
||||
const [paymentMethod, setPaymentMethod] = useState<'square' | 'cash' | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [createdMembershipId, setCreatedMembershipId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadTiers();
|
||||
@@ -32,45 +35,74 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
setStep('payment');
|
||||
};
|
||||
|
||||
const handlePayment = async () => {
|
||||
if (!selectedTier) return;
|
||||
const handleCashPayment = async () => {
|
||||
if (!selectedTier || !createdMembershipId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Calculate dates (start today, end one year from now)
|
||||
const startDate = new Date().toISOString().split('T')[0];
|
||||
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
// Create membership
|
||||
const membershipData: MembershipCreateData = {
|
||||
tier_id: selectedTier.id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
auto_renew: false
|
||||
};
|
||||
|
||||
const membership = await membershipService.createMembership(membershipData);
|
||||
|
||||
// Create fake payment
|
||||
// Create cash/dummy payment
|
||||
const paymentData: PaymentCreateData = {
|
||||
amount: selectedTier.annual_fee,
|
||||
payment_method: 'dummy',
|
||||
membership_id: membership.id,
|
||||
notes: `Fake payment for ${selectedTier.name} membership`
|
||||
payment_method: 'cash',
|
||||
membership_id: createdMembershipId,
|
||||
notes: `Cash payment for ${selectedTier.name} membership`
|
||||
};
|
||||
|
||||
await paymentService.createPayment(paymentData);
|
||||
|
||||
setStep('confirm');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to create membership');
|
||||
setError(err.response?.data?.detail || 'Failed to record payment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSquarePaymentSuccess = (paymentResult: any) => {
|
||||
console.log('Square payment successful:', paymentResult);
|
||||
// Payment was successful, membership was created and activated by the backend
|
||||
setStep('confirm');
|
||||
};
|
||||
|
||||
const handleSquarePaymentError = (error: string) => {
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handlePaymentMethodSelect = async (method: 'square' | 'cash') => {
|
||||
setPaymentMethod(method);
|
||||
|
||||
if (!selectedTier) return;
|
||||
|
||||
// For cash payments, create membership in PENDING state
|
||||
// For Square payments, we'll create membership only after successful payment
|
||||
if (method === 'cash') {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const startDate = new Date().toISOString().split('T')[0];
|
||||
const endDate = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
const membershipData: MembershipCreateData = {
|
||||
tier_id: selectedTier.id,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
auto_renew: false
|
||||
};
|
||||
|
||||
const membership = await membershipService.createMembership(membershipData);
|
||||
setCreatedMembershipId(membership.id);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to create membership');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
// For Square, just set the payment method - membership created after successful payment
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onMembershipCreated();
|
||||
};
|
||||
@@ -144,49 +176,159 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ backgroundColor: '#fff3cd', border: '1px solid #ffeaa7', borderRadius: '4px', padding: '16px', marginBottom: '20px' }}>
|
||||
<strong>Demo Payment</strong>
|
||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||
This is a Cash payment flow for demo purposes. Square / Paypal etc will come soon
|
||||
</p>
|
||||
</div>
|
||||
{!paymentMethod && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ marginBottom: '16px' }}>Choose Payment Method</h4>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => handlePaymentMethodSelect('square')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>Credit/Debit Card</strong>
|
||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
||||
Pay securely with Square
|
||||
</div>
|
||||
</div>
|
||||
<span>→</span>
|
||||
</button>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handlePayment}
|
||||
disabled={loading}
|
||||
style={{ marginRight: '10px' }}
|
||||
>
|
||||
{loading ? 'Processing...' : 'Complete Cash Payment'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setStep('select')}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => handlePaymentMethodSelect('cash')}
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '16px',
|
||||
textAlign: 'left',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong>Cash Payment</strong>
|
||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
||||
Pay in person or by check
|
||||
</div>
|
||||
</div>
|
||||
<span>→</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setStep('select')}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'square' && selectedTier && (
|
||||
<div>
|
||||
<SquarePayment
|
||||
amount={selectedTier.annual_fee}
|
||||
tierId={selectedTier.id}
|
||||
onPaymentSuccess={handleSquarePaymentSuccess}
|
||||
onPaymentError={handleSquarePaymentError}
|
||||
/>
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setPaymentMethod(null);
|
||||
setError('');
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Choose Different Payment Method
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'cash' && createdMembershipId && (
|
||||
<div>
|
||||
<div style={{
|
||||
backgroundColor: '#fff3cd',
|
||||
border: '1px solid #ffeaa7',
|
||||
borderRadius: '4px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px'
|
||||
}}>
|
||||
<strong>Cash Payment Selected</strong>
|
||||
<p style={{ marginTop: '8px', marginBottom: 0 }}>
|
||||
Your membership will be marked as pending until an administrator confirms payment receipt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleCashPayment}
|
||||
disabled={loading}
|
||||
style={{ marginRight: '10px' }}
|
||||
>
|
||||
{loading ? 'Processing...' : 'Confirm Cash Payment'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setPaymentMethod(null);
|
||||
setCreatedMembershipId(null);
|
||||
setError('');
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Choose Different Payment Method
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'confirm') {
|
||||
const isCashPayment = paymentMethod === 'cash';
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3 style={{ marginBottom: '16px' }}>Membership Created Successfully!</h3>
|
||||
<h3 style={{ marginBottom: '16px' }}>
|
||||
{isCashPayment ? 'Membership Application Submitted!' : 'Payment Successful!'}
|
||||
</h3>
|
||||
|
||||
{selectedTier && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4>Your Membership Details:</h4>
|
||||
<p><strong>Tier:</strong> {selectedTier.name}</p>
|
||||
<p><strong>Annual Fee:</strong> £{selectedTier.annual_fee.toFixed(2)}</p>
|
||||
<p><strong>Status:</strong> <span className="status-badge status-pending">Pending</span></p>
|
||||
<p><strong>Status:</strong>
|
||||
<span className={`status-badge ${isCashPayment ? 'status-pending' : 'status-active'}`}>
|
||||
{isCashPayment ? 'Pending' : 'Active'}
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ fontSize: '14px', color: '#666', marginTop: '12px' }}>
|
||||
Your membership application has been submitted. An administrator will review and activate your membership shortly.
|
||||
{isCashPayment
|
||||
? 'Your membership application has been submitted. An administrator will review and activate your membership once payment is confirmed.'
|
||||
: 'Thank you for your payment! Your membership has been activated and is now live. You can start enjoying your membership benefits immediately.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
175
frontend/src/components/SquarePayment.tsx
Normal file
175
frontend/src/components/SquarePayment.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
interface SquarePaymentProps {
|
||||
amount: number;
|
||||
onPaymentSuccess: (paymentResult: any) => void;
|
||||
onPaymentError: (error: string) => void;
|
||||
tierId: number;
|
||||
}
|
||||
|
||||
const SquarePayment: React.FC<SquarePaymentProps> = ({
|
||||
amount,
|
||||
onPaymentSuccess,
|
||||
onPaymentError,
|
||||
tierId
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [card, setCard] = useState<any>(null);
|
||||
const [payments, setPayments] = useState<any>(null);
|
||||
const [squareConfig, setSquareConfig] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSquareConfig();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (squareConfig && !payments) {
|
||||
initializeSquare();
|
||||
}
|
||||
}, [squareConfig]);
|
||||
|
||||
const loadSquareConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/payments/config/square', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const config = await response.json();
|
||||
setSquareConfig(config);
|
||||
} catch (error) {
|
||||
console.error('Failed to load Square config:', error);
|
||||
onPaymentError('Failed to load payment configuration');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initializeSquare = async () => {
|
||||
if (!window.Square) {
|
||||
console.error('Square.js failed to load');
|
||||
onPaymentError('Payment system failed to load. Please refresh the page.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const paymentsInstance = window.Square.payments(
|
||||
squareConfig.application_id,
|
||||
squareConfig.location_id
|
||||
);
|
||||
setPayments(paymentsInstance);
|
||||
|
||||
const cardInstance = await paymentsInstance.card();
|
||||
await cardInstance.attach('#card-container');
|
||||
setCard(cardInstance);
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Square:', error);
|
||||
onPaymentError('Failed to initialize payment form');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePayment = async () => {
|
||||
if (!card || !payments) {
|
||||
onPaymentError('Payment form not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Tokenize the payment method
|
||||
const result = await card.tokenize();
|
||||
|
||||
if (result.status === 'OK') {
|
||||
// Send the token to your backend
|
||||
const response = await fetch('/api/v1/payments/square/process', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
source_id: result.token,
|
||||
amount: amount,
|
||||
tier_id: tierId,
|
||||
note: `Membership payment - £${amount.toFixed(2)}`
|
||||
})
|
||||
});
|
||||
|
||||
const paymentResult = await response.json();
|
||||
|
||||
if (response.ok && paymentResult.success) {
|
||||
onPaymentSuccess(paymentResult);
|
||||
} else {
|
||||
// Handle error response gracefully
|
||||
let errorMessage = 'Payment failed. Please try again.';
|
||||
|
||||
if (paymentResult.errors && Array.isArray(paymentResult.errors) && paymentResult.errors.length > 0) {
|
||||
// Show the first error message (they're already user-friendly from backend)
|
||||
errorMessage = paymentResult.errors[0];
|
||||
} else if (paymentResult.detail) {
|
||||
errorMessage = paymentResult.detail;
|
||||
}
|
||||
|
||||
onPaymentError(errorMessage);
|
||||
}
|
||||
} else {
|
||||
const errors = result.errors?.map((e: any) => e.message).join(', ') || 'Card tokenization failed';
|
||||
onPaymentError(errors);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Payment error:', error);
|
||||
onPaymentError(error.message || 'Payment processing failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '400px', margin: '0 auto' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ marginBottom: '10px' }}>Card Payment</h4>
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
Amount: <strong>£{amount.toFixed(2)}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="card-container"
|
||||
style={{
|
||||
minHeight: '200px',
|
||||
marginBottom: '20px'
|
||||
}}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePayment}
|
||||
disabled={isLoading || !card}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{isLoading ? 'Processing...' : `Pay £${amount.toFixed(2)}`}
|
||||
</button>
|
||||
|
||||
<div style={{ marginTop: '16px', fontSize: '12px', color: '#666', textAlign: 'center' }}>
|
||||
<p>Secure payment powered by Square</p>
|
||||
{squareConfig?.environment === 'sandbox' && (
|
||||
<p style={{ color: '#ff9800', marginTop: '8px' }}>
|
||||
<strong>Test Mode:</strong> Use card 4111 1111 1111 1111 with any future expiry
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Extend Window interface for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
Square: any;
|
||||
}
|
||||
}
|
||||
|
||||
export default SquarePayment;
|
||||
@@ -27,7 +27,16 @@ const Login: React.FC = () => {
|
||||
await authService.login(formData);
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Login failed. Please check your credentials.');
|
||||
console.error('Login error:', err.response?.data); // Debug log
|
||||
const errorDetail = err.response?.data?.detail;
|
||||
if (typeof errorDetail === 'string') {
|
||||
setError(errorDetail);
|
||||
} else if (errorDetail && typeof errorDetail === 'object') {
|
||||
// Handle validation error objects
|
||||
setError('Login failed. Please check your credentials.');
|
||||
} else {
|
||||
setError('Login failed. Please check your credentials.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { membershipService, MembershipTier, MembershipTierCreateData, MembershipTierUpdateData } from '../services/membershipService';
|
||||
|
||||
const MembershipTiers: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [tiers, setTiers] = useState<MembershipTier[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
@@ -82,31 +84,65 @@ const MembershipTiers: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '20px' }}>
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#f8f9fa',
|
||||
padding: '20px'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '30px'
|
||||
maxWidth: '1200px',
|
||||
margin: '0 auto',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<h1 style={{ margin: 0, color: '#333' }}>Membership Tiers Management</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#28a745',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px'
|
||||
}}
|
||||
>
|
||||
Create New Tier
|
||||
</button>
|
||||
</div>
|
||||
<div style={{
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, fontSize: '24px' }}>Membership Tiers Management</h1>
|
||||
<p style={{ margin: '5px 0 0 0', opacity: 0.9 }}>
|
||||
Manage membership tiers and pricing
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255,255,255,0.3)',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Create New Tier
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}>
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ display: 'grid', gap: '20px', gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}>
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
@@ -196,6 +232,8 @@ const MembershipTiers: React.FC = () => {
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export default defineConfig({
|
||||
usePolling: true
|
||||
},
|
||||
hmr: {
|
||||
clientPort: 3500
|
||||
clientPort: 8050
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
|
||||
Reference in New Issue
Block a user