Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 | 1x 15x 15x 15x 15x 15x 9x 1x 1x 1x 8x 1x 1x 7x 1x 1x 6x 6x 6x 4x 4x 2x 2x 2x 2x 2x 2x 4x 4x 4x 15x 1x 1x 14x 1x 1x 13x 24x 9x | // src/pages/admin/components/AdminBrandManager.tsx
import React, { useState } from 'react';
import toast from 'react-hot-toast';
import { uploadBrandLogo } from '../../../services/apiClient';
import { Brand } from '../../../types';
import { ErrorDisplay } from '../../../components/ErrorDisplay';
import { useBrandsQuery } from '../../../hooks/queries/useBrandsQuery';
import { logger } from '../../../services/logger.client';
export const AdminBrandManager: React.FC = () => {
const { data: initialBrands, isLoading: loading, error } = useBrandsQuery();
// This state will hold a modified list of brands only after an optimistic update (e.g., logo upload).
// It starts as null, indicating that we should use the original data from the API.
const [updatedBrands, setUpdatedBrands] = useState<Brand[] | null>(null);
// At render time, decide which data to display. If updatedBrands exists, it takes precedence.
// Otherwise, fall back to the initial data from the hook. Default to an empty array.
const brandsToRender: Brand[] = updatedBrands || initialBrands || [];
logger.debug(
{
loading,
error: error?.message,
hasInitialBrands: !!initialBrands,
hasUpdatedBrands: !!updatedBrands,
brandsToRenderCount: brandsToRender.length,
},
'[AdminBrandManager] Render',
);
// The file parameter is now optional to handle cases where the user cancels the file picker.
const handleLogoUpload = async (brandId: number, file: File | undefined) => {
if (!file) {
// This check is now the single source of truth for a missing file.
logger.debug('[AdminBrandManager] handleLogoUpload called with no file. Showing error toast');
toast.error('Please select a file to upload.');
return;
}
if (!['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'].includes(file.type)) {
toast.error('Invalid file type. Please upload a PNG, JPG, WEBP, or SVG.');
return;
}
if (file.size > 2 * 1024 * 1024) {
// 2MB limit
toast.error('File is too large. Maximum size is 2MB.');
return;
}
const toastId = toast.loading('Uploading logo...');
try {
const response = await uploadBrandLogo(brandId, file);
logger.debug(
{
ok: response.ok,
status: response.status,
statusText: response.statusText,
},
'[AdminBrandManager] Logo upload response received',
);
// Check for a successful response before attempting to parse JSON.
if (!response.ok) {
const errorBody = await response.text();
throw new Error(errorBody || `Upload failed with status ${response.status}`);
}
const { logoUrl } = await response.json();
toast.success('Logo updated successfully!', { id: toastId });
// Optimistically update the UI by setting the updatedBrands state.
// This update is based on the currently rendered list of brands.
logger.debug(
{ brandId, logoUrl },
'[AdminBrandManager] Optimistically updating brand with new logo',
);
setUpdatedBrands(
brandsToRender.map((brand) =>
brand.brand_id === brandId ? { ...brand, logo_url: logoUrl } : brand,
),
);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : String(e);
toast.error(`Upload failed: ${errorMessage}`, { id: toastId });
}
};
if (loading) {
logger.debug('[AdminBrandManager] Rendering the loading state');
return <div className="text-center p-4">Loading brands...</div>;
}
if (error) {
logger.error({ err: error }, '[AdminBrandManager] Rendering the error state');
return <ErrorDisplay message={`Failed to load brands: ${error.message}`} />;
}
return (
<div className="bg-white dark:bg-gray-800 shadow-md rounded-lg p-6">
<h2 className="text-2xl font-semibold text-gray-800 dark:text-white mb-4">
Brand Management
</h2>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Logo
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Brand Name
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider"
>
Upload New Logo
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{brandsToRender.map((brand) => (
<tr key={brand.brand_id}>
<td className="px-6 py-4 whitespace-nowrap">
{brand.logo_url ? (
<img
src={brand.logo_url}
alt={`${brand.name} logo`}
className="h-10 w-10 object-contain rounded-md bg-gray-100 dark:bg-gray-700 p-1"
/>
) : (
<div className="h-10 w-10 flex items-center justify-center bg-gray-200 dark:bg-gray-700 rounded-md text-gray-400 text-xs">
No Logo
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{brand.name}
{brand.store_name && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
({brand.store_name})
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<input
// Add an accessible label to make the input findable in tests.
aria-label={`Upload logo for ${brand.name}`}
type="file"
accept="image/png, image/jpeg, image/webp, image/svg+xml"
// The onChange handler now always calls handleLogoUpload.
// Optional chaining (`?.`) safely passes the first file or `undefined`.
onChange={(e) => handleLogoUpload(brand.brand_id, e.target.files?.[0])}
className="text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-brand-light file:text-brand-dark hover:file:bg-brand-primary/20"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
|