- Callback failures now redirect with the underlying error message, shown in the Settings toast, instead of a generic failure - Token endpoint requests use application/x-www-form-urlencoded per RFC 6749 instead of JSON Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
441 lines
No EOL
17 KiB
TypeScript
441 lines
No EOL
17 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useDispatch } from 'react-redux';
|
|
import { DataManager } from '../utils/dataManager';
|
|
import { setOrders } from '../store/slices/orderSlice';
|
|
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink } from 'lucide-react';
|
|
import toast from 'react-hot-toast';
|
|
import api from '../utils/api';
|
|
|
|
interface EtsyStatus {
|
|
connected: boolean;
|
|
shopId?: string;
|
|
shopName?: string;
|
|
lastSyncedAt?: string;
|
|
}
|
|
|
|
interface EtsyConfig {
|
|
configured: boolean;
|
|
apiKeyMasked: string | null;
|
|
redirectUri: string | null;
|
|
source: 'database' | 'environment' | null;
|
|
}
|
|
|
|
const Settings = () => {
|
|
const dispatch = useDispatch();
|
|
const [storageSummary, setStorageSummary] = useState({
|
|
orders: 0,
|
|
products: 0,
|
|
customers: 0,
|
|
expenses: 0,
|
|
totalItems: 0
|
|
});
|
|
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
|
const [etsyStatus, setEtsyStatus] = useState<EtsyStatus | null>(null);
|
|
const [etsyConfig, setEtsyConfig] = useState<EtsyConfig | null>(null);
|
|
const [apiKeyInput, setApiKeyInput] = useState('');
|
|
const [redirectUriInput, setRedirectUriInput] = useState('');
|
|
const [isSavingConfig, setIsSavingConfig] = useState(false);
|
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [unmatchedItems, setUnmatchedItems] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
updateStorageSummary();
|
|
loadEtsyStatus();
|
|
loadEtsyConfig();
|
|
|
|
// Handle the redirect back from the Etsy consent screen
|
|
const params = new URLSearchParams(window.location.search);
|
|
const etsyResult = params.get('etsy');
|
|
if (etsyResult === 'connected') {
|
|
toast.success('Etsy shop connected!');
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
} else if (etsyResult === 'error') {
|
|
const reason = params.get('reason');
|
|
toast.error(
|
|
reason ? `Etsy connection failed: ${reason}` : 'Etsy connection failed. Please try again.',
|
|
{ duration: 12000 }
|
|
);
|
|
window.history.replaceState({}, '', window.location.pathname);
|
|
}
|
|
}, []);
|
|
|
|
const loadEtsyStatus = async () => {
|
|
try {
|
|
const res = await api.get('/etsy/status');
|
|
setEtsyStatus(res.data);
|
|
} catch {
|
|
setEtsyStatus(null);
|
|
}
|
|
};
|
|
|
|
const loadEtsyConfig = async () => {
|
|
try {
|
|
const res = await api.get('/etsy/config');
|
|
setEtsyConfig(res.data);
|
|
// Prefill the callback URL: stored value, or this app's own callback route
|
|
setRedirectUriInput(res.data.redirectUri || `${window.location.origin}/api/etsy/callback`);
|
|
} catch {
|
|
setEtsyConfig(null);
|
|
setRedirectUriInput(`${window.location.origin}/api/etsy/callback`);
|
|
}
|
|
};
|
|
|
|
const handleSaveEtsyConfig = async () => {
|
|
if (!apiKeyInput.trim() && !etsyConfig?.configured) {
|
|
toast.error('Enter your Etsy API keystring');
|
|
return;
|
|
}
|
|
setIsSavingConfig(true);
|
|
try {
|
|
await api.put('/etsy/config', {
|
|
apiKey: apiKeyInput.trim() || undefined,
|
|
redirectUri: redirectUriInput.trim(),
|
|
});
|
|
toast.success('Etsy configuration saved');
|
|
setApiKeyInput('');
|
|
loadEtsyConfig();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to save Etsy configuration');
|
|
} finally {
|
|
setIsSavingConfig(false);
|
|
}
|
|
};
|
|
|
|
const handleEtsyConnect = async () => {
|
|
setIsConnecting(true);
|
|
try {
|
|
const res = await api.get('/etsy/connect');
|
|
window.location.href = res.data.url;
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Failed to start Etsy connection');
|
|
setIsConnecting(false);
|
|
}
|
|
};
|
|
|
|
const handleEtsySync = async () => {
|
|
setIsSyncing(true);
|
|
setUnmatchedItems([]);
|
|
try {
|
|
const res = await api.post('/etsy/sync');
|
|
const { created, updated, unmatchedItems: unmatched, receiptsSeen } = res.data;
|
|
toast.success(`Synced ${receiptsSeen} Etsy orders: ${created} new, ${updated} updated`);
|
|
if (unmatched?.length > 0) {
|
|
setUnmatchedItems(unmatched);
|
|
}
|
|
// Refresh orders in the app
|
|
const ordersRes = await api.get('/orders?limit=1000');
|
|
dispatch(setOrders(ordersRes.data.orders));
|
|
loadEtsyStatus();
|
|
} catch (error: any) {
|
|
toast.error(error.response?.data?.message || 'Etsy sync failed');
|
|
} finally {
|
|
setIsSyncing(false);
|
|
}
|
|
};
|
|
|
|
const handleEtsyDisconnect = async () => {
|
|
if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return;
|
|
try {
|
|
await api.delete('/etsy/connection');
|
|
setEtsyStatus({ connected: false });
|
|
toast.success('Etsy disconnected');
|
|
} catch {
|
|
toast.error('Failed to disconnect Etsy');
|
|
}
|
|
};
|
|
|
|
const updateStorageSummary = () => {
|
|
setStorageSummary(DataManager.getStorageSummary());
|
|
};
|
|
|
|
const handleClearData = () => {
|
|
DataManager.clearAllData();
|
|
updateStorageSummary();
|
|
setShowClearConfirm(false);
|
|
toast.success('All data cleared successfully!');
|
|
|
|
// Reload the page to reset Redux state
|
|
window.location.reload();
|
|
};
|
|
|
|
const handleClearWithBackup = () => {
|
|
DataManager.clearWithBackup();
|
|
updateStorageSummary();
|
|
setShowClearConfirm(false);
|
|
toast.success('Data cleared and backup downloaded!');
|
|
|
|
// Reload the page to reset Redux state
|
|
window.location.reload();
|
|
};
|
|
|
|
const handleExportData = () => {
|
|
const backup = DataManager.exportAllData();
|
|
const blob = new Blob([backup], { type: 'application/json' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `etsy-tracker-export-${new Date().toISOString().split('T')[0]}.json`;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
toast.success('Data exported successfully!');
|
|
};
|
|
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto">
|
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Settings</h1>
|
|
|
|
{/* Etsy Integration Section */}
|
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
<Store className="w-6 h-6 text-orange-500" />
|
|
Etsy Integration
|
|
</h2>
|
|
|
|
{etsyStatus?.connected ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<div>
|
|
<p className="text-green-800 font-medium">
|
|
Connected{etsyStatus.shopName ? ` to ${etsyStatus.shopName}` : ''}
|
|
</p>
|
|
<p className="text-green-700 text-sm mt-1">
|
|
{etsyStatus.lastSyncedAt
|
|
? `Last synced ${new Date(etsyStatus.lastSyncedAt).toLocaleString('en-AU')}`
|
|
: 'Not synced yet'}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={handleEtsySync}
|
|
disabled={isSyncing}
|
|
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-400"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
|
{isSyncing ? 'Syncing...' : 'Sync Orders Now'}
|
|
</button>
|
|
<button
|
|
onClick={handleEtsyDisconnect}
|
|
className="flex items-center gap-2 px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
|
|
>
|
|
<Unlink className="w-4 h-4" />
|
|
Disconnect
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{unmatchedItems.length > 0 && (
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
|
<p className="text-yellow-800 font-medium text-sm mb-2">
|
|
{unmatchedItems.length} item title(s) couldn't be matched to your product catalog
|
|
(their orders were synced without cost data):
|
|
</p>
|
|
<ul className="text-yellow-700 text-sm list-disc list-inside space-y-1">
|
|
{unmatchedItems.map((title, idx) => (
|
|
<li key={idx}>{title}</li>
|
|
))}
|
|
</ul>
|
|
<p className="text-yellow-700 text-xs mt-2">
|
|
Add these as products (or aliases on existing products) and sync again to fill in costs.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<p className="text-gray-600 text-sm">
|
|
Connect your Etsy shop to sync orders automatically — order details, items,
|
|
variations, and tracking, with costs matched from your product catalog.
|
|
</p>
|
|
|
|
{/* API credentials, stored in the database */}
|
|
<div className="bg-gray-50 rounded-lg p-4 space-y-3">
|
|
<h3 className="text-sm font-medium text-gray-900">API Configuration</h3>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Etsy API Keystring
|
|
</label>
|
|
<input
|
|
type="password"
|
|
autoComplete="off"
|
|
placeholder={etsyConfig?.apiKeyMasked
|
|
? `Saved (${etsyConfig.apiKeyMasked}) — enter a new key to replace it`
|
|
: 'Paste your keystring from etsy.com/developers'}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
|
value={apiKeyInput}
|
|
onChange={(e) => setApiKeyInput(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Callback URL
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
|
value={redirectUriInput}
|
|
onChange={(e) => setRedirectUriInput(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Register this exact URL as a callback URL in your Etsy app settings
|
|
(etsy.com/developers) before connecting.
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleSaveEtsyConfig}
|
|
disabled={isSavingConfig}
|
|
className="px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 disabled:bg-gray-400 text-sm"
|
|
>
|
|
{isSavingConfig ? 'Saving…' : 'Save Configuration'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleEtsyConnect}
|
|
disabled={isConnecting || !etsyConfig?.configured}
|
|
title={!etsyConfig?.configured ? 'Save your API configuration first' : undefined}
|
|
className="flex items-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 disabled:bg-gray-400"
|
|
>
|
|
<Link2 className="w-4 h-4" />
|
|
{isConnecting ? 'Redirecting…' : 'Connect Etsy Shop'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Data Management Section */}
|
|
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
|
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
|
<Database className="w-6 h-6" />
|
|
Data Management
|
|
</h2>
|
|
|
|
{/* Current Data Summary */}
|
|
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-3">Current Data Storage</h3>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-blue-600">{storageSummary.orders}</p>
|
|
<p className="text-sm text-gray-600">Orders</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-green-600">{storageSummary.products}</p>
|
|
<p className="text-sm text-gray-600">Products</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-purple-600">{storageSummary.customers}</p>
|
|
<p className="text-sm text-gray-600">Customers</p>
|
|
</div>
|
|
<div className="text-center">
|
|
<p className="text-2xl font-bold text-orange-600">{storageSummary.expenses}</p>
|
|
<p className="text-sm text-gray-600">Expenses</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 pt-4 border-t border-gray-200 text-center">
|
|
<p className="text-lg font-semibold text-gray-900">
|
|
Total Items: <span className="text-blue-600">{storageSummary.totalItems}</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
onClick={updateStorageSummary}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Refresh Count
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleExportData}
|
|
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Export Data
|
|
</button>
|
|
|
|
<button
|
|
onClick={() => setShowClearConfirm(true)}
|
|
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
Clear All Data
|
|
</button>
|
|
</div>
|
|
|
|
{/* Testing Helper */}
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
|
<h4 className="text-blue-800 font-semibold mb-2">🧪 Testing Mode</h4>
|
|
<p className="text-blue-700 text-sm mb-3">
|
|
Clear all data to test the import functionality with fresh data. Your data will be backed up automatically.
|
|
</p>
|
|
<button
|
|
onClick={handleClearWithBackup}
|
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Clear Data + Download Backup
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Clear Confirmation Modal */}
|
|
{showClearConfirm && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-lg p-6 max-w-md mx-4">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<AlertTriangle className="w-8 h-8 text-red-600" />
|
|
<h3 className="text-lg font-semibold text-gray-900">Clear All Data?</h3>
|
|
</div>
|
|
|
|
<p className="text-gray-600 mb-4">
|
|
This will permanently delete all your orders, products, customers, and expenses.
|
|
This action cannot be undone.
|
|
</p>
|
|
|
|
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mb-4">
|
|
<p className="text-yellow-800 text-sm">
|
|
<strong>Current data:</strong> {storageSummary.totalItems} total items
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<button
|
|
onClick={() => setShowClearConfirm(false)}
|
|
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleClearWithBackup}
|
|
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
|
>
|
|
Clear + Backup
|
|
</button>
|
|
<button
|
|
onClick={handleClearData}
|
|
className="flex-1 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
|
>
|
|
Clear Only
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Other Settings Sections */}
|
|
<div className="bg-white rounded-lg shadow-md p-6">
|
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Application Settings</h2>
|
|
<p className="text-gray-600">Additional application settings will be available here in future updates.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Settings; |