From 1e9f6481b001a69b6a797b8bf032cd02ab59933e Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sun, 23 Aug 2026 15:01:53 +0330 Subject: [PATCH] Fix portfolio list sorting UX and add name/category filters. Newest portfolios show first with corrected move-up, category no longer mixes into the date, and website link generation can filter by tags. Co-authored-by: Cursor --- .../src/components/ConfirmDeleteModal.tsx | 8 +- .../GenerateWebsiteLinkModal.module.css | 7 + .../components/GenerateWebsiteLinkModal.tsx | 84 ++++++-- .../src/components/PortfolioCard.module.css | 22 ++- .../business/src/components/PortfolioCard.tsx | 15 +- apps/business/src/i18n/messages.ts | 36 +++- apps/business/src/pages/PortfolioListPage.tsx | 181 ++++++++++++++++-- apps/business/src/utils/websiteLinks.ts | 20 ++ 8 files changed, 324 insertions(+), 49 deletions(-) diff --git a/apps/business/src/components/ConfirmDeleteModal.tsx b/apps/business/src/components/ConfirmDeleteModal.tsx index f101a31..6be2cd2 100644 --- a/apps/business/src/components/ConfirmDeleteModal.tsx +++ b/apps/business/src/components/ConfirmDeleteModal.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { createPortal } from 'react-dom' import { AlertTriangle, X } from 'lucide-react' +import { useT } from '../i18n/useT' import styles from './ConfirmDeleteModal.module.css' interface ConfirmDeleteModalProps { @@ -20,6 +21,7 @@ export function ConfirmDeleteModal({ onConfirm, onCancel, }: ConfirmDeleteModalProps) { + const t = useT() const [mounted, setMounted] = useState(open) const [closing, setClosing] = useState(false) @@ -71,14 +73,14 @@ export function ConfirmDeleteModal({
- diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.module.css b/apps/business/src/components/GenerateWebsiteLinkModal.module.css index 0e4cf21..ce003f9 100644 --- a/apps/business/src/components/GenerateWebsiteLinkModal.module.css +++ b/apps/business/src/components/GenerateWebsiteLinkModal.module.css @@ -51,6 +51,13 @@ word-break: break-all; } +.hint { + margin: 6px 0 0; + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); +} + .error { margin: 0; font-size: 12px; diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.tsx b/apps/business/src/components/GenerateWebsiteLinkModal.tsx index cb1cbd8..9a50f38 100644 --- a/apps/business/src/components/GenerateWebsiteLinkModal.tsx +++ b/apps/business/src/components/GenerateWebsiteLinkModal.tsx @@ -13,14 +13,17 @@ import type { ProductCategory } from '../types/category' import { WEBSITE_LINK_KINDS, buildWebsiteCategoryUrl, + buildWebsiteTagsUrl, + type WebsiteLinkFilter, type WebsiteLinkKind, } from '../utils/websiteLinks' import { CategoryTreeSelect } from './CategoryTreeSelect' +import { TagInput } from './TagInput' import modalStyles from './CategoryModal.module.css' import styles from './GenerateWebsiteLinkModal.module.css' export type { WebsiteLinkKind } -export { buildWebsiteCategoryUrl } +export { buildWebsiteCategoryUrl, buildWebsiteTagsUrl } const KIND_LABEL: Record = { product: 'website.linkGenerator.types.product', @@ -91,8 +94,10 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs const [mounted, setMounted] = useState(open) const [closing, setClosing] = useState(false) const [kind, setKind] = useState('product') + const [filterBy, setFilterBy] = useState('category') const [categories, setCategories] = useState([]) const [categoryId, setCategoryId] = useState('') + const [tags, setTags] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState('') @@ -101,8 +106,10 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs setMounted(true) setClosing(false) setKind('product') + setFilterBy('category') setCategories([]) setCategoryId('') + setTags([]) setError('') } else if (mounted) { setClosing(true) @@ -127,7 +134,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs }, [mounted, closing, onClose]) useEffect(() => { - if (!open) return + if (!open || filterBy !== 'category') return const controller = new AbortController() setLoading(true) @@ -152,7 +159,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs }) return () => controller.abort() - }, [open, kind, t]) + }, [open, kind, filterBy, t]) const uiCategories = useMemo( () => categories.map(mapProductCategoryToUi), @@ -160,7 +167,12 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs ) const selected = categories.find((category) => category.id === categoryId) - const preview = selected ? buildWebsiteCategoryUrl(kind, selected) : '' + const preview = + filterBy === 'category' + ? selected + ? buildWebsiteCategoryUrl(kind, selected) + : '' + : buildWebsiteTagsUrl(kind, tags) if (!mounted) return null @@ -171,6 +183,16 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs onPick(preview) } + function handleFilterChange(next: WebsiteLinkFilter) { + setFilterBy(next) + setError('') + if (next === 'tag') { + setCategoryId('') + } else { + setTags([]) + } + } + return createPortal(
-
- - {loading ? ( -

{t('website.linkGenerator.loading')}

- ) : uiCategories.length === 0 ? ( -

{t('website.linkGenerator.empty')}

- ) : ( - - )} +
+ +
+ {filterBy === 'category' ? ( +
+ + {loading ? ( +

{t('website.linkGenerator.loading')}

+ ) : uiCategories.length === 0 ? ( +

{t('website.linkGenerator.empty')}

+ ) : ( + + )} +
+ ) : ( +
+ + +

{t('website.linkGenerator.tagsHint')}

+
+ )} + {preview ? (

{preview} diff --git a/apps/business/src/components/PortfolioCard.module.css b/apps/business/src/components/PortfolioCard.module.css index 8046bbb..4be8a12 100644 --- a/apps/business/src/components/PortfolioCard.module.css +++ b/apps/business/src/components/PortfolioCard.module.css @@ -115,6 +115,24 @@ text-align: left; } +.categoryChip { + display: inline-block; + align-self: flex-start; + max-width: 100%; + padding: 3px 10px; + font-family: var(--font-ui); + font-size: 11px; + font-weight: 500; + line-height: 1.35; + color: var(--text-secondary); + background: rgba(148, 163, 184, 0.15); + border-radius: 50px; + unicode-bidi: isolate; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .meta { margin: 0; font-size: 12px; @@ -122,6 +140,6 @@ color: var(--text-muted); } -.metaDot { - margin: 0 6px; +.metaDate { + unicode-bidi: isolate; } diff --git a/apps/business/src/components/PortfolioCard.tsx b/apps/business/src/components/PortfolioCard.tsx index e113e73..3dbb5cc 100644 --- a/apps/business/src/components/PortfolioCard.tsx +++ b/apps/business/src/components/PortfolioCard.tsx @@ -94,14 +94,15 @@ export function PortfolioCard({

) ) : null} + {portfolio.categoryName ? ( + + {portfolio.categoryName} + + ) : null}

- {portfolio.categoryName ? ( - <> - {portfolio.categoryName} - · - - ) : null} - {publishDate} + + {publishDate} +

diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index 6726a36..29478b1 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -1371,11 +1371,19 @@ const en = { 'portfolio.card.settings.link': 'View settings', 'portfolio.list.subtitle': '{count} items · View, edit and manage your portfolio content.', + 'portfolio.list.filters': 'Filters', + 'portfolio.list.filterName': 'Name', + 'portfolio.list.filterNamePlaceholder': 'Search by portfolio name', + 'portfolio.list.filterCategory': 'Category', + 'portfolio.list.filterCategoryAll': 'All categories', + 'portfolio.list.search': 'Search', + 'portfolio.list.clearFilters': 'Clear filters', 'portfolio.list.loading': 'Loading portfolios...', 'portfolio.list.empty': 'You have not added any portfolio items yet.', 'portfolio.list.emptyNone': 'no portfolio items', 'portfolio.list.emptyAction': 'adding a portfolio item', 'portfolio.list.emptyCta': 'Add portfolio', + 'portfolio.list.emptyFiltered': 'No portfolios match your filters.', 'portfolio.list.errorLoad': 'Unable to load portfolios.', 'portfolio.list.errorDelete': 'Unable to delete portfolio.', 'portfolio.list.errorMove': 'Unable to move portfolio.', @@ -1526,9 +1534,15 @@ const en = { 'website.linkGenerator.generate': 'Generate', 'website.linkGenerator.title': 'Generate link', - 'website.linkGenerator.subtitle': 'Pick a content type, then a category. The link is inserted into the field.', + 'website.linkGenerator.subtitle': + 'Pick a content type, then filter by category or tag. The link is inserted into the field.', 'website.linkGenerator.type': 'Type', + 'website.linkGenerator.filterBy': 'Filter by', 'website.linkGenerator.category': 'Category', + 'website.linkGenerator.tag': 'Tag', + 'website.linkGenerator.tags': 'Tags', + 'website.linkGenerator.tagsPlaceholder': 'Type a tag and press Enter', + 'website.linkGenerator.tagsHint': 'Add one or more tags. Press Enter after each tag.', 'website.linkGenerator.pickCategory': 'Search categories...', 'website.linkGenerator.insert': 'Insert link', 'website.linkGenerator.loading': 'Loading categories...', @@ -2156,6 +2170,8 @@ const en = { 'otp.error.signIn': 'Unable to sign in with SMS code.', 'common.close': 'Close', + 'common.cancel': 'Cancel', + 'common.delete': 'Delete', 'common.resendIn': 'Resend code in {seconds}s', 'common.resend': 'Resend SMS code', 'common.codeSent': 'Verification code sent to {phone}', @@ -3535,11 +3551,19 @@ const fa: Record = { 'portfolio.card.settings.link': 'مشاهده تنظیمات', 'portfolio.list.subtitle': '{count} مورد · مشاهده، ویرایش و مدیریت محتوای نمونه کار.', + 'portfolio.list.filters': 'فیلترها', + 'portfolio.list.filterName': 'نام', + 'portfolio.list.filterNamePlaceholder': 'جستجو بر اساس نام نمونه کار', + 'portfolio.list.filterCategory': 'دسته‌بندی', + 'portfolio.list.filterCategoryAll': 'همه دسته‌بندی‌ها', + 'portfolio.list.search': 'جستجو', + 'portfolio.list.clearFilters': 'پاک کردن فیلترها', 'portfolio.list.loading': 'در حال بارگذاری نمونه کارها...', 'portfolio.list.empty': 'هنوز نمونه کاری ثبت نکرده‌اید.', 'portfolio.list.emptyNone': 'هیچ نمونه کاری', 'portfolio.list.emptyAction': 'ثبت نمونه کار', 'portfolio.list.emptyCta': 'افزودن نمونه کار', + 'portfolio.list.emptyFiltered': 'هیچ نمونه کاری با فیلترهای شما مطابقت ندارد.', 'portfolio.list.errorLoad': 'بارگذاری نمونه کارها ممکن نشد.', 'portfolio.list.errorDelete': 'حذف نمونه کار ممکن نشد.', 'portfolio.list.errorMove': 'جابه‌جایی نمونه کار ممکن نشد.', @@ -3690,9 +3714,15 @@ const fa: Record = { 'website.linkGenerator.generate': 'ساخت لینک', 'website.linkGenerator.title': 'ساخت لینک', - 'website.linkGenerator.subtitle': 'نوع محتوا را انتخاب کنید، سپس یک دسته‌بندی. لینک در فیلد درج می‌شود.', + 'website.linkGenerator.subtitle': + 'نوع محتوا را انتخاب کنید، سپس فیلتر بر اساس دسته‌بندی یا برچسب. لینک در فیلد درج می‌شود.', 'website.linkGenerator.type': 'نوع', + 'website.linkGenerator.filterBy': 'فیلتر بر اساس', 'website.linkGenerator.category': 'دسته‌بندی', + 'website.linkGenerator.tag': 'برچسب', + 'website.linkGenerator.tags': 'برچسب‌ها', + 'website.linkGenerator.tagsPlaceholder': 'برچسب را بنویسید و Enter بزنید', + 'website.linkGenerator.tagsHint': 'یک یا چند برچسب اضافه کنید. بعد از هر برچسب Enter بزنید.', 'website.linkGenerator.pickCategory': 'جستجوی دسته‌بندی‌ها...', 'website.linkGenerator.insert': 'درج لینک', 'website.linkGenerator.loading': 'در حال بارگذاری دسته‌بندی‌ها...', @@ -4321,6 +4351,8 @@ const fa: Record = { 'otp.error.signIn': 'ورود با کد پیامکی ممکن نشد.', 'common.close': 'بستن', + 'common.cancel': 'انصراف', + 'common.delete': 'حذف', 'common.resendIn': 'ارسال مجدد کد تا {seconds} ثانیه', 'common.resend': 'ارسال مجدد کد پیامکی', 'common.codeSent': 'کد تأیید به {phone} ارسال شد', diff --git a/apps/business/src/pages/PortfolioListPage.tsx b/apps/business/src/pages/PortfolioListPage.tsx index 4b64046..c142865 100644 --- a/apps/business/src/pages/PortfolioListPage.tsx +++ b/apps/business/src/pages/PortfolioListPage.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router-dom' -import { Briefcase, Plus } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { useNavigate, useSearchParams } from 'react-router-dom' +import { Briefcase, Plus, RotateCcw, Search } from 'lucide-react' +import { useLocale } from '@meshkee/dashboard-ui' import { Breadcrumbs } from '../components/Breadcrumbs' import { ListEmptyState } from '../components/EmptyState' import { PageTitle } from '../components/PageTitle' @@ -17,17 +18,47 @@ import { listPortfolios, updatePortfolio, } from '../services/portfolioService' +import { + listPortfolioCategories, + mapPortfolioCategoryToUi, +} from '../services/portfolioCategoryService' +import type { FlatCategory } from '../types/category' import type { Portfolio } from '../types/portfolio' +import { flattenCategories } from '../utils/categories' import pageStyles from '../components/PageContent.module.css' +import filterStyles from '../components/ListFiltersPanel.module.css' import styles from './BlogPage.module.css' +function readListQuery(searchParams: URLSearchParams) { + const name = searchParams.get('q')?.trim() ?? '' + const categoryId = searchParams.get('categoryId')?.trim() ?? '' + const pageRaw = Number(searchParams.get('page')) + const page = Number.isFinite(pageRaw) && pageRaw >= 1 ? Math.floor(pageRaw) : 1 + return { name, categoryId, page } +} + +function categoryFilterLabel(category: FlatCategory, isFa: boolean) { + const name = isFa + ? category.nameFa || category.nameEn + : category.nameEn || category.nameFa + return category.depth > 0 ? `${'\u2014 '.repeat(category.depth)}${name}` : name +} + export function PortfolioListPage() { const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() const { showToast } = useToast() const t = useT() + const { locale } = useLocale() + const isFa = locale === 'fa' + const { + name: appliedName, + categoryId: appliedCategoryId, + page: currentPage, + } = readListQuery(searchParams) + const [portfolios, setPortfolios] = useState([]) const [totalPortfolios, setTotalPortfolios] = useState(0) - const [currentPage, setCurrentPage] = useState(1) const [isLoading, setIsLoading] = useState(true) const [isDeleting, setIsDeleting] = useState(false) const [movingUpId, setMovingUpId] = useState(null) @@ -37,21 +68,64 @@ export function PortfolioListPage() { const [commentsTarget, setCommentsTarget] = useState<{ id: string; title: string } | null>( null, ) + const [draftName, setDraftName] = useState(appliedName) + const [draftCategoryId, setDraftCategoryId] = useState(appliedCategoryId) + const [categoryOptions, setCategoryOptions] = useState([]) const totalPages = Math.max(1, Math.ceil(totalPortfolios / PORTFOLIOS_PER_PAGE)) + const hasFilters = Boolean(appliedName || appliedCategoryId) + const categorySelectOptions = useMemo( + () => + categoryOptions.map((category) => ({ + id: category.id, + label: categoryFilterLabel(category, isFa), + })), + [categoryOptions, isFa], + ) + + useEffect(() => { + setDraftName(appliedName) + setDraftCategoryId(appliedCategoryId) + }, [appliedName, appliedCategoryId]) useEffect(() => { const controller = new AbortController() void loadPortfolios(currentPage, controller.signal) return () => controller.abort() - }, [currentPage]) + }, [currentPage, appliedName, appliedCategoryId]) + + useEffect(() => { + const controller = new AbortController() + void loadCategories(controller.signal) + return () => controller.abort() + }, []) + + function writeListQuery(name: string, categoryId: string, page: number) { + const next = new URLSearchParams() + if (name) next.set('q', name) + if (categoryId) next.set('categoryId', categoryId) + if (page > 1) next.set('page', String(page)) + setSearchParams(next, { replace: true }) + } + + async function loadCategories(signal?: AbortSignal) { + try { + const items = await listPortfolioCategories(signal) + setCategoryOptions(flattenCategories(items.map(mapPortfolioCategoryToUi))) + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + } + } async function loadPortfolios(page: number, signal?: AbortSignal) { setIsLoading(true) setError('') try { - const data = await listPortfolios(page, PORTFOLIOS_PER_PAGE, signal) + const data = await listPortfolios(page, PORTFOLIOS_PER_PAGE, signal, { + ...(appliedName ? { title: appliedName } : {}), + ...(appliedCategoryId ? { categoryId: appliedCategoryId } : {}), + }) setPortfolios(data.items) setTotalPortfolios(data.total) setCommentCounts( @@ -69,6 +143,16 @@ export function PortfolioListPage() { } } + function applyFilters() { + writeListQuery(draftName.trim(), draftCategoryId, 1) + } + + function clearFilters() { + setDraftName('') + setDraftCategoryId('') + writeListQuery('', '', 1) + } + function handleEdit(id: string) { navigate(`/portfolios/edit/${id}`) } @@ -98,11 +182,11 @@ export function PortfolioListPage() { try { if (index === 0) { - await updatePortfolio(current.id, { sortOrder: current.sortOrder - 1 }) + await updatePortfolio(current.id, { sortOrder: current.sortOrder + 1 }) } else { const previous = portfolios[index - 1] if (current.sortOrder === previous.sortOrder) { - await updatePortfolio(current.id, { sortOrder: previous.sortOrder - 1 }) + await updatePortfolio(current.id, { sortOrder: previous.sortOrder + 1 }) } else { await Promise.all([ updatePortfolio(current.id, { sortOrder: previous.sortOrder }), @@ -136,7 +220,7 @@ export function PortfolioListPage() { const nextTotalPages = Math.max(1, Math.ceil(nextTotal / PORTFOLIOS_PER_PAGE)) const nextPage = currentPage > nextTotalPages ? nextTotalPages : currentPage setDeleteTarget(null) - setCurrentPage(nextPage) + writeListQuery(appliedName, appliedCategoryId, nextPage) await loadPortfolios(nextPage) } catch (err) { if (err instanceof ApiError) { @@ -150,7 +234,7 @@ export function PortfolioListPage() { } function handlePageChange(page: number) { - setCurrentPage(page) + writeListQuery(appliedName, appliedCategoryId, page) window.scrollTo({ top: 0, behavior: 'smooth' }) } @@ -181,6 +265,67 @@ export function PortfolioListPage() { +
+
{t('portfolio.list.filters')}
+
{ + e.preventDefault() + applyFilters() + }} + > +
+
+ setDraftName(e.target.value)} + placeholder={t('portfolio.list.filterNamePlaceholder')} + aria-label={t('portfolio.list.filterName')} + disabled={isLoading} + /> +
+
+ +
+
+
+ + +
+
+
+ {error && (
{error} @@ -190,12 +335,16 @@ export function PortfolioListPage() { {isLoading ? (

{t('portfolio.list.loading')}

) : portfolios.length === 0 ? ( - + hasFilters ? ( +

{t('portfolio.list.emptyFiltered')}

+ ) : ( + + ) ) : ( <>
diff --git a/apps/business/src/utils/websiteLinks.ts b/apps/business/src/utils/websiteLinks.ts index a1478c0..02043f6 100644 --- a/apps/business/src/utils/websiteLinks.ts +++ b/apps/business/src/utils/websiteLinks.ts @@ -2,6 +2,8 @@ import { getBusinessDomain } from '../lib/config' export type WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video' +export type WebsiteLinkFilter = 'category' | 'tag' + export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [ 'product', 'storeItem', @@ -35,3 +37,21 @@ export function buildWebsiteCategoryUrl( : `categoryId=${encodeURIComponent(category.id)}` return `${getWebsiteOrigin(domain)}${path}?${query}` } + +export function buildWebsiteTagsUrl( + kind: WebsiteLinkKind, + tags: string[], + domain = getBusinessDomain(), +): string { + const cleaned = [ + ...new Set(tags.map((tag) => tag.trim()).filter(Boolean)), + ] + if (cleaned.length === 0) return '' + + const path = WEBSITE_LINK_KIND_PATH[kind] + const query = + cleaned.length === 1 + ? `tag=${encodeURIComponent(cleaned[0])}` + : `tags=${cleaned.map((tag) => encodeURIComponent(tag)).join(',')}` + return `${getWebsiteOrigin(domain)}${path}?${query}` +}