etsy-finance-tracker/client/src/pages/Settings.tsx
dlawler489 08160775e7 Surface Etsy OAuth errors and use form-encoded token requests
- 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>
2026-06-13 11:09:09 +10:00

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;