Add store-product Excel bulk edit and polish the business dashboard.
Export/import price and stock from Store Products, keep card order after saves, localize variations, and drop unused website nav items. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
7b51dda82a
commit
832ac32a09
@@ -100,6 +100,16 @@ button.emptyCta {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: storeItemsSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes storeItemsSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
padding: 0 4px;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Package, Plus, RotateCcw, Search } from 'lucide-react'
|
||||
import { Download, Loader2, Package, Plus, RotateCcw, Search, Upload } from 'lucide-react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { CreateStoreItemsModal } from '../components/CreateStoreItemsModal'
|
||||
import { EditStoreItemsModal } from '../components/EditStoreItemsModal'
|
||||
import { Pagination } from '../components/Pagination'
|
||||
import { Tooltip } from '../components/Tooltip'
|
||||
import { PickStoreVariantModal } from '../components/PickStoreVariantModal'
|
||||
import { ShoppingCartModal } from '../components/ShoppingCartModal'
|
||||
import { StoreItemCard } from '../components/StoreItemCard'
|
||||
@@ -18,6 +19,8 @@ import { useToast } from '../context/ToastContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
deleteStoreItemsByProduct,
|
||||
downloadStoreItemsExcel,
|
||||
importStoreItemsExcel,
|
||||
listAllStoreItems,
|
||||
STORE_ITEMS_PER_PAGE,
|
||||
type StoreItem,
|
||||
@@ -75,6 +78,9 @@ function StoreItemsPageContent() {
|
||||
const [appliedFilters, setAppliedFilters] = useState<StoreListingFilters>(
|
||||
EMPTY_STORE_LISTING_FILTERS,
|
||||
)
|
||||
const [excelBusy, setExcelBusy] = useState<'export' | 'import' | null>(null)
|
||||
const [productOrder, setProductOrder] = useState<string[]>([])
|
||||
const importInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasItems) {
|
||||
@@ -102,7 +108,10 @@ function StoreItemsPageContent() {
|
||||
}
|
||||
}, [hasItems, loadShoppingCard, showToast])
|
||||
|
||||
const listings = useMemo(() => groupStoreItemsByProduct(items), [items])
|
||||
const listings = useMemo(
|
||||
() => groupStoreItemsByProduct(items, productOrder),
|
||||
[items, productOrder],
|
||||
)
|
||||
const filteredListings = useMemo(
|
||||
() => filterStoreListings(listings, appliedFilters),
|
||||
[listings, appliedFilters],
|
||||
@@ -177,6 +186,7 @@ function StoreItemsPageContent() {
|
||||
try {
|
||||
const data = await listAllStoreItems(signal)
|
||||
setItems(data)
|
||||
setProductOrder(groupStoreItemsByProduct(data).map((listing) => listing.productId))
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
if (err instanceof ApiError) {
|
||||
@@ -197,6 +207,10 @@ function StoreItemsPageContent() {
|
||||
const withoutProduct = prev.filter((item) => item.productId !== productId)
|
||||
return updated.length > 0 ? [...withoutProduct, ...updated] : withoutProduct
|
||||
})
|
||||
|
||||
if (updated.length === 0) {
|
||||
setProductOrder((prev) => prev.filter((id) => id !== productId))
|
||||
}
|
||||
}
|
||||
|
||||
function mergeUpdatedItems(updated: StoreItem[]) {
|
||||
@@ -244,6 +258,64 @@ function StoreItemsPageContent() {
|
||||
showToast(t('storeItems.addedToCart'), 'success')
|
||||
}
|
||||
|
||||
async function handleExportExcel() {
|
||||
if (excelBusy) return
|
||||
setExcelBusy('export')
|
||||
setError('')
|
||||
try {
|
||||
await downloadStoreItemsExcel()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('storeItems.excel.exportError'))
|
||||
}
|
||||
} finally {
|
||||
setExcelBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
function openImportPicker() {
|
||||
if (excelBusy) return
|
||||
importInputRef.current?.click()
|
||||
}
|
||||
|
||||
async function handleImportExcel(fileList: FileList | null) {
|
||||
const file = fileList?.[0]
|
||||
if (importInputRef.current) {
|
||||
importInputRef.current.value = ''
|
||||
}
|
||||
if (!file) return
|
||||
|
||||
const name = file.name.toLowerCase()
|
||||
if (!name.endsWith('.xlsx')) {
|
||||
setError(t('storeItems.excel.importEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
setExcelBusy('import')
|
||||
setError('')
|
||||
try {
|
||||
const result = await importStoreItemsExcel(file)
|
||||
showToast(
|
||||
t('storeItems.excel.importSuccess', {
|
||||
updated: result.updated,
|
||||
unchanged: result.unchanged,
|
||||
}),
|
||||
'success',
|
||||
)
|
||||
await loadItems()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError(t('storeItems.excel.importError'))
|
||||
}
|
||||
} finally {
|
||||
setExcelBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!deleteTarget) return
|
||||
|
||||
@@ -253,6 +325,7 @@ function StoreItemsPageContent() {
|
||||
try {
|
||||
await deleteStoreItemsByProduct(deleteTarget.productId)
|
||||
setItems((prev) => prev.filter((item) => item.productId !== deleteTarget.productId))
|
||||
setProductOrder((prev) => prev.filter((id) => id !== deleteTarget.productId))
|
||||
setDeleteTarget(null)
|
||||
|
||||
const nextTotal = Math.max(0, totalListings - 1)
|
||||
@@ -342,25 +415,64 @@ function StoreItemsPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className={filterStyles.filterActions}>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
disabled={isLoading}
|
||||
aria-label={t('storeItems.search')}
|
||||
title={t('storeItems.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={isLoading}
|
||||
aria-label={t('storeItems.clearFilters')}
|
||||
title={t('storeItems.clearFilters')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
hidden
|
||||
onChange={(e) => void handleImportExcel(e.target.files)}
|
||||
/>
|
||||
<Tooltip label={t('storeItems.excel.export')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={() => void handleExportExcel()}
|
||||
disabled={isLoading || !!excelBusy || listings.length === 0}
|
||||
aria-label={t('storeItems.excel.export')}
|
||||
>
|
||||
{excelBusy === 'export' ? (
|
||||
<Loader2 size={18} className={styles.spin} />
|
||||
) : (
|
||||
<Download size={18} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('storeItems.excel.import')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={openImportPicker}
|
||||
disabled={isLoading || !!excelBusy}
|
||||
aria-label={t('storeItems.excel.import')}
|
||||
>
|
||||
{excelBusy === 'import' ? (
|
||||
<Loader2 size={18} className={styles.spin} />
|
||||
) : (
|
||||
<Upload size={18} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('storeItems.search')}>
|
||||
<button
|
||||
type="submit"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnPrimary}`}
|
||||
disabled={isLoading || !!excelBusy}
|
||||
aria-label={t('storeItems.search')}
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('storeItems.clearFilters')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${filterStyles.iconActionBtn} ${filterStyles.iconActionBtnGhost}`}
|
||||
onClick={clearFilters}
|
||||
disabled={isLoading || !!excelBusy}
|
||||
aria-label={t('storeItems.clearFilters')}
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user