From f622e6d605c860bd382cc389d1941a9a44cbe5fc Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Mon, 10 Aug 2026 21:31:28 +0330 Subject: [PATCH] Add customer/manager access UI and tighten customers filters. Business and super-admin can set Customer vs Manager roles (Admin only for super-admin), and the customers page drops clutter while filtering by all/customers/managers. Co-authored-by: Cursor --- .../ChangeUserAccessModal.module.css | 56 +++ .../src/components/ChangeUserAccessModal.tsx | 229 ++++++++++++ .../business/src/components/Header.module.css | 10 +- .../components/ListFiltersPanel.module.css | 13 + .../src/components/ProductDetailsTabs.tsx | 96 +++-- .../src/components/RichTextEditor.module.css | 8 + .../src/components/RichTextEditor.tsx | 77 +++- .../src/components/UserProductCard.module.css | 9 +- apps/business/src/i18n/messages.ts | 138 ++++++- apps/business/src/pages/BlogListPage.tsx | 18 +- apps/business/src/pages/BlogPage.module.css | 76 ++++ .../CustomerProductDetailsPage.module.css | 8 +- .../src/pages/CustomersPage.module.css | 19 +- apps/business/src/pages/CustomersPage.tsx | 124 ++++++- .../business/src/pages/ProductDetailsPage.tsx | 66 ++-- apps/business/src/services/customerService.ts | 8 + apps/business/src/services/teamService.ts | 44 +++ .../customer/src/components/Header.module.css | 9 +- .../src/components/UserProductCard.module.css | 9 +- apps/customer/src/pages/LoginPage.module.css | 6 + .../src/pages/MyProductDetailsPage.module.css | 8 +- .../src/components/Header.module.css | 9 +- .../src/components/RichTextEditor.module.css | 8 + .../src/components/RichTextEditor.tsx | 76 +++- .../src/pages/UsersPage.module.css | 8 + apps/super-admin/src/pages/UsersPage.tsx | 348 +++++++++++------- apps/super-admin/src/services/teamService.ts | 37 ++ docs/PROJECT_CONTEXT.md | 4 +- 28 files changed, 1280 insertions(+), 241 deletions(-) create mode 100644 apps/business/src/components/ChangeUserAccessModal.module.css create mode 100644 apps/business/src/components/ChangeUserAccessModal.tsx create mode 100644 apps/business/src/services/teamService.ts diff --git a/apps/business/src/components/ChangeUserAccessModal.module.css b/apps/business/src/components/ChangeUserAccessModal.module.css new file mode 100644 index 0000000..c4a92e5 --- /dev/null +++ b/apps/business/src/components/ChangeUserAccessModal.module.css @@ -0,0 +1,56 @@ +.roleList { + display: flex; + flex-direction: column; + gap: 8px; +} + +.roleOption { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid rgba(15, 23, 42, 0.1); + background: rgba(255, 255, 255, 0.45); + cursor: pointer; + font-family: var(--font-ui); +} + +.roleOption:has(input:checked) { + border-color: rgba(var(--primary-rgb) / 0.35); + background: rgba(var(--primary-rgb) / 0.08); +} + +.roleOptionLabel { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.roleHint { + margin: 12px 0 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.45; + font-family: var(--font-ui); +} + +.roleListNested { + margin-top: 4px; + margin-inline-start: 12px; + padding-inline-start: 10px; + border-inline-start: 2px solid rgba(var(--primary-rgb) / 0.2); +} + +.roleBadge { + display: inline-block; + margin-top: 4px; + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + color: var(--primary); + background: rgba(var(--primary-rgb) / 0.1); + border: 1px solid rgba(var(--primary-rgb) / 0.18); + font-family: var(--font-ui); +} diff --git a/apps/business/src/components/ChangeUserAccessModal.tsx b/apps/business/src/components/ChangeUserAccessModal.tsx new file mode 100644 index 0000000..bfeffea --- /dev/null +++ b/apps/business/src/components/ChangeUserAccessModal.tsx @@ -0,0 +1,229 @@ +import { useEffect, useMemo, useState } from 'react' +import { createPortal } from 'react-dom' +import { X } from 'lucide-react' +import { useT } from '../i18n/useT' +import { ApiError } from '../lib/api' +import { + assignBusinessAccess, + type BusinessAccess, +} from '../services/teamService' +import type { BusinessCustomerListItem } from '../services/customerService' +import modalStyles from './VariationsModal.module.css' +import styles from './ChangeUserAccessModal.module.css' + +const ANIMATION_MS = 220 + +const ALL_TEAM_ROLES = ['admin', 'editor', 'viewer'] as const +const BUSINESS_TEAM_ROLES = ['editor', 'viewer'] as const + +export interface ChangeUserAccessModalProps { + open: boolean + user: BusinessCustomerListItem | null + /** When true, Admin team role is offered (super-admin only). */ + canAssignAdmin: boolean + onClose: () => void + onSaved: (user: BusinessCustomerListItem) => void +} + +function displayName(user: BusinessCustomerListItem) { + const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim() + return name || '—' +} + +export function ChangeUserAccessModal({ + open, + user, + canAssignAdmin, + onClose, + onSaved, +}: ChangeUserAccessModalProps) { + const t = useT() + const [mounted, setMounted] = useState(open) + const [closing, setClosing] = useState(false) + const [access, setAccess] = useState('customer') + const [teamRole, setTeamRole] = useState('editor') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState('') + + const teamRoles = useMemo( + () => (canAssignAdmin ? [...ALL_TEAM_ROLES] : [...BUSINESS_TEAM_ROLES]), + [canAssignAdmin], + ) + + useEffect(() => { + if (open && user) { + setMounted(true) + setClosing(false) + const isStaff = Boolean(user.businessMemberId) && !user.isBusinessOwner + setAccess(isStaff ? 'staff' : 'customer') + const current = user.teamRole + if (isStaff && current && teamRoles.includes(current as (typeof ALL_TEAM_ROLES)[number])) { + setTeamRole(current) + } else { + setTeamRole(teamRoles[0] ?? 'editor') + } + setError('') + } else if (mounted) { + setClosing(true) + const timer = setTimeout(() => { + setMounted(false) + setClosing(false) + }, ANIMATION_MS) + return () => clearTimeout(timer) + } + }, [open, user, mounted, teamRoles]) + + useEffect(() => { + if (!mounted || closing) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [mounted, closing, onClose]) + + if (!mounted || !user) return null + + const canSave = + !user.isBusinessOwner && (access === 'customer' || Boolean(teamRole)) + + async function handleSubmit() { + if (!user || !canSave) return + setSubmitting(true) + setError('') + try { + const result = await assignBusinessAccess({ + userId: user.id, + access, + roleSlug: access === 'staff' ? teamRole : undefined, + }) + + onSaved({ + ...user, + businessMemberId: + access === 'staff' + ? result.member?.id != null + ? String(result.member.id) + : user.businessMemberId + : null, + isBusinessOwner: false, + teamRole: access === 'staff' ? teamRole : null, + }) + onClose() + } catch (err) { + setError(err instanceof ApiError ? err.message : t('customers.access.error')) + } finally { + setSubmitting(false) + } + } + + return createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="change-access-title" + > +
+
+

+ {t('customers.access.title')} +

+

+ {t('customers.access.subtitle', { name: displayName(user) })} +

+
+ +
+ +
+ {error ?

{error}

: null} + +
+ + +
+ + {access === 'staff' ? ( + <> +

+ {canAssignAdmin + ? t('customers.access.hintSuperAdmin') + : t('customers.access.hintBusiness')} +

+
+ {teamRoles.map((slug) => ( + + ))} +
+ + ) : null} + +
+ + +
+
+
+
, + document.body, + ) +} diff --git a/apps/business/src/components/Header.module.css b/apps/business/src/components/Header.module.css index 646905b..e67342b 100644 --- a/apps/business/src/components/Header.module.css +++ b/apps/business/src/components/Header.module.css @@ -3,13 +3,15 @@ align-items: center; justify-content: space-between; padding: 16px 32px; - background: var(--glass-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-bottom: 1px solid var(--glass-border); position: sticky; top: 0; z-index: 50; + background: color-mix(in srgb, #ffffff 42%, transparent); + backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px)); + -webkit-backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px)); + border-bottom: 1px solid var(--glass-border); + /* Keep sticky header on its own compositor layer so backdrop blur samples content beneath */ + transform: translateZ(0); } .left { diff --git a/apps/business/src/components/ListFiltersPanel.module.css b/apps/business/src/components/ListFiltersPanel.module.css index 220b7a2..4aaadbd 100644 --- a/apps/business/src/components/ListFiltersPanel.module.css +++ b/apps/business/src/components/ListFiltersPanel.module.css @@ -90,6 +90,19 @@ .field select { padding: var(--field-padding-y) var(--select-padding-end) var(--field-padding-y) var(--field-padding-x); + appearance: none; + -webkit-appearance: none; + background-color: rgba(255, 255, 255, 0.65); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right var(--select-arrow-offset) center; + background-size: var(--select-arrow-size); + cursor: pointer; +} + +:global([dir='rtl']) .field select { + padding: var(--field-padding-y) var(--field-padding-x) var(--field-padding-y) var(--select-padding-end); + background-position: left var(--select-arrow-offset) center; } .field input:focus, diff --git a/apps/business/src/components/ProductDetailsTabs.tsx b/apps/business/src/components/ProductDetailsTabs.tsx index bf6ed9b..598cef4 100644 --- a/apps/business/src/components/ProductDetailsTabs.tsx +++ b/apps/business/src/components/ProductDetailsTabs.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Check, Star, ThumbsUp, Trash2, XCircle } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { formatCommentDate } from '../data/productComments' +import { useT } from '../i18n/useT' import { ApiError } from '../lib/api' import { deleteComment, @@ -21,6 +22,7 @@ import type { ProductTechnicalValue } from '../types/technicalForm' import styles from './ProductDetailsTabs.module.css' type DetailTab = 'technical' | 'comments' | 'reviews' +type TechnicalEmptyReason = 'no-category' | 'no-form' | 'no-data' interface ProductDetailsTabsProps { productId: string @@ -39,11 +41,14 @@ function formatTechnicalValue(item: ProductTechnicalValue): string { } export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTabsProps) { + const t = useT() const { locale } = useLocale() const dateLocale = locale === 'fa' ? 'fa' : 'en' const [activeTab, setActiveTab] = useState('technical') const [technicalValues, setTechnicalValues] = useState([]) - const [technicalMessage, setTechnicalMessage] = useState('') + const [technicalEmptyReason, setTechnicalEmptyReason] = useState( + null, + ) const [technicalLoading, setTechnicalLoading] = useState(false) const [technicalError, setTechnicalError] = useState('') const [comments, setComments] = useState([]) @@ -90,7 +95,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa if (err instanceof ApiError) { setCommentsError(err.message) } else { - setCommentsError('Unable to load comments.') + setCommentsError(t('products.details.comments.errorLoad')) } } finally { setCommentsLoading(false) @@ -109,7 +114,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa if (err instanceof ApiError) { setReviewsError(err.message) } else { - setReviewsError('Unable to load expert reviews.') + setReviewsError(t('products.details.reviews.errorLoad')) } } finally { setReviewsLoading(false) @@ -119,24 +124,24 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa async function loadTechnicalInfo(signal?: AbortSignal) { setTechnicalLoading(true) setTechnicalError('') - setTechnicalMessage('') + setTechnicalEmptyReason(null) try { const data = await getProductTechnicalInfo(productId, signal) setTechnicalValues(data.values) - if (data.message) { - setTechnicalMessage(data.message) + if (data.message === 'Product has no category assigned') { + setTechnicalEmptyReason('no-category') } else if (!data.form) { - setTechnicalMessage('No technical form is defined for this product category.') + setTechnicalEmptyReason('no-form') } else if (!data.values.some((item) => formatTechnicalValue(item) !== '—')) { - setTechnicalMessage('No technical data has been added for this product yet.') + setTechnicalEmptyReason('no-data') } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof ApiError) { setTechnicalError(err.message) } else { - setTechnicalError('Unable to load technical info.') + setTechnicalError(t('products.details.technical.errorLoad')) } } finally { setTechnicalLoading(false) @@ -154,7 +159,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa if (err instanceof ApiError) { setCommentsError(err.message) } else { - setCommentsError('Unable to update comment approval.') + setCommentsError(t('products.details.comments.errorApprove')) } } finally { setActionId(null) @@ -172,7 +177,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa if (err instanceof ApiError) { setCommentsError(err.message) } else { - setCommentsError('Unable to delete comment.') + setCommentsError(t('products.details.comments.errorDelete')) } } finally { setActionId(null) @@ -190,7 +195,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa if (err instanceof ApiError) { setReviewsError(err.message) } else { - setReviewsError('Unable to update expert review approval.') + setReviewsError(t('products.details.reviews.errorApprove')) } } finally { setActionId(null) @@ -208,7 +213,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa if (err instanceof ApiError) { setReviewsError(err.message) } else { - setReviewsError('Unable to delete expert review.') + setReviewsError(t('products.details.reviews.errorDelete')) } } finally { setActionId(null) @@ -216,10 +221,18 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa } const displayedCommentCount = comments.length > 0 ? comments.length : commentCount + const technicalEmptyMessage = + technicalEmptyReason === 'no-category' + ? t('products.details.technical.noCategory') + : technicalEmptyReason === 'no-form' + ? t('products.details.technical.noForm') + : technicalEmptyReason === 'no-data' + ? t('products.details.technical.noData') + : '' return (
-
+
) : ( )}
@@ -355,9 +368,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa <> {reviewsError &&

{reviewsError}

} {reviewsLoading ? ( -

Loading expert reviews...

+

{t('products.details.reviews.loading')}

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

No expert reviews yet.

+

{t('products.details.reviews.empty')}

) : (
{reviews.map((review) => ( @@ -372,7 +385,10 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa {formatReviewDate(review.createdAt, dateLocale)}
-
+
{review.rate}/10
@@ -382,7 +398,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa
{review.positivePoints.length > 0 && (
-

Positive points

+

+ {t('products.details.positivePoints')} +

    {review.positivePoints.map((point) => (
  • {point}
  • @@ -392,7 +410,9 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa )} {review.negativePoints.length > 0 && (
    -

    Negative points

    +

    + {t('products.details.negativePoints')} +

      {review.negativePoints.map((point) => (
    • {point}
    • @@ -406,7 +426,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa

      {review.text}

      {!review.approved && ( - Pending approval + {t('products.details.pending')} )}
      @@ -418,7 +438,7 @@ export function ProductDetailsTabs({ productId, commentCount }: ProductDetailsTa onClick={() => void toggleReviewApproval(review)} > - Reject + {t('products.details.reject')} ) : ( )}
      diff --git a/apps/business/src/components/RichTextEditor.module.css b/apps/business/src/components/RichTextEditor.module.css index b7e7f5d..4d4f8eb 100644 --- a/apps/business/src/components/RichTextEditor.module.css +++ b/apps/business/src/components/RichTextEditor.module.css @@ -99,6 +99,14 @@ margin-bottom: 0; } +.editor blockquote { + margin: 0.5em 0; + margin-inline-start: 1.5em; + margin-inline-end: 0; + padding: 0; + border: 0; +} + .editor img { max-width: 100%; height: auto; diff --git a/apps/business/src/components/RichTextEditor.tsx b/apps/business/src/components/RichTextEditor.tsx index f8f09f2..8d728a0 100644 --- a/apps/business/src/components/RichTextEditor.tsx +++ b/apps/business/src/components/RichTextEditor.tsx @@ -1,9 +1,21 @@ import { useRef, useEffect, useState, useCallback } from 'react' -import { Bold, Italic, Underline, List, ListOrdered, ImagePlus } from 'lucide-react' +import { + Bold, + IndentDecrease, + IndentIncrease, + Italic, + Underline, + List, + ListOrdered, + ImagePlus, +} from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { uploadMediaFiles } from '../services/mediaService' import styles from './RichTextEditor.module.css' +const INDENT_STEP_PX = 24 +const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4']) + interface RichTextEditorProps { value: string onChange: (value: string) => void @@ -132,6 +144,51 @@ export function RichTextEditor({ syncChange() } + function findIndentBlock(editor: HTMLElement): HTMLElement | null { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return null + + let node: Node | null = selection.anchorNode + while (node && node !== editor) { + if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) { + return node + } + node = node.parentNode + } + return null + } + + function changeIndent(direction: 1 | -1) { + const editor = editorRef.current + if (!editor) return + + editor.focus() + const block = findIndentBlock(editor) + if (!block) { + document.execCommand(direction > 0 ? 'indent' : 'outdent') + syncChange() + return + } + + const current = + Number.parseFloat(block.style.paddingInlineStart) || + Number.parseFloat(getComputedStyle(block).paddingInlineStart) || + 0 + const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX)) + if (next === 0) { + block.style.paddingInlineStart = '' + } else { + block.style.paddingInlineStart = `${next}px` + } + syncChange() + } + + function onEditorKeyDown(e: React.KeyboardEvent) { + if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return + e.preventDefault() + changeIndent(e.shiftKey ? -1 : 1) + } + function styleInsertedImage(img: HTMLImageElement) { img.style.width = '100%' img.style.maxWidth = '100%' @@ -223,6 +280,23 @@ export function RichTextEditor({ + + + {allowImages && ( <> @@ -262,6 +336,7 @@ export function RichTextEditor({ lang={locale} data-placeholder={placeholder} onInput={syncChange} + onKeyDown={onEditorKeyDown} suppressContentEditableWarning /> {allowImages && selectedImageEl && handlePos && ( diff --git a/apps/business/src/components/UserProductCard.module.css b/apps/business/src/components/UserProductCard.module.css index 1f1755d..539c2b5 100644 --- a/apps/business/src/components/UserProductCard.module.css +++ b/apps/business/src/components/UserProductCard.module.css @@ -66,13 +66,14 @@ } .badge[data-status='draft'] { - color: #fff; + color: #0f172a; background: linear-gradient( 145deg, - rgba(251, 191, 36, 0.55) 0%, - rgba(245, 158, 11, 0.32) 100% + rgba(255, 255, 255, 0.78) 0%, + rgba(255, 255, 255, 0.42) 100% ); - border-color: rgba(251, 191, 36, 0.35); + border-color: rgba(255, 255, 255, 0.55); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } .badge[data-status='published'] { diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts index d214c23..3836791 100644 --- a/apps/business/src/i18n/messages.ts +++ b/apps/business/src/i18n/messages.ts @@ -407,6 +407,43 @@ const en = { 'products.card.remove': 'Remove product', 'products.card.draft': 'Draft', + 'products.details.loading': 'Loading product...', + 'products.details.notFound': 'Product not found.', + 'products.details.errorLoad': 'Unable to load product.', + 'products.details.back': 'Back to My Products', + 'products.details.openGallery': 'Open image gallery', + 'products.details.noImage': 'No image', + 'products.details.viewImage': 'View image {index}', + 'products.details.tabsAria': 'Product details', + 'products.details.tab.technical': 'Technical Info', + 'products.details.tab.comments': 'Comments', + 'products.details.tab.reviews': 'Expert Reviews', + 'products.details.technical.loading': 'Loading technical info...', + 'products.details.technical.noForm': + 'No technical form is defined for this product category.', + 'products.details.technical.noData': + 'No technical data has been added for this product yet.', + 'products.details.technical.noCategory': 'Product has no category assigned.', + 'products.details.technical.empty': 'No technical data available.', + 'products.details.technical.errorLoad': 'Unable to load technical info.', + 'products.details.comments.loading': 'Loading comments...', + 'products.details.comments.empty': 'No comments yet for this product.', + 'products.details.comments.errorLoad': 'Unable to load comments.', + 'products.details.comments.errorApprove': 'Unable to update comment approval.', + 'products.details.comments.errorDelete': 'Unable to delete comment.', + 'products.details.reviews.loading': 'Loading expert reviews...', + 'products.details.reviews.empty': 'No expert reviews yet.', + 'products.details.reviews.errorLoad': 'Unable to load expert reviews.', + 'products.details.reviews.errorApprove': 'Unable to update expert review approval.', + 'products.details.reviews.errorDelete': 'Unable to delete expert review.', + 'products.details.pending': 'Pending approval', + 'products.details.approve': 'Approve', + 'products.details.reject': 'Reject', + 'products.details.remove': 'Remove', + 'products.details.positivePoints': 'Positive points', + 'products.details.negativePoints': 'Negative points', + 'products.details.ratingAria': 'Rating {rate} out of 10', + 'products.form.errorOptions': 'Unable to load form options.', 'products.form.errorSave': 'Unable to save product.', 'products.form.thumbnail': 'Thumbnail Image', @@ -735,9 +772,13 @@ const en = { 'customers.filters': 'Filters', 'customers.filter.name': 'Search by name', 'customers.filter.cell': 'Cell number', + 'customers.filter.access': 'User role', + 'customers.filter.access.all': 'All users', + 'customers.filter.access.customers': 'Customers', + 'customers.filter.access.managers': 'Managers', 'customers.search': 'Search', 'customers.clearFilters': 'Clear filters', - 'customers.listTitle': 'Customer list', + 'customers.listTitle': 'User list', 'customers.showing': 'Showing {from} - {to} of {total}', 'customers.none': 'No customers', 'customers.empty': 'No results found.', @@ -792,6 +833,29 @@ const en = { 'customers.addModal.error': 'Unable to add customer.', 'customers.addModal.errorPasswordMatch': 'Passwords do not match.', 'customers.addModal.errorPasswordLength': 'Password must be at least 8 characters.', + 'customers.access.title': 'Change access', + 'customers.access.subtitle': 'Choose customer or manager access for {name}.', + 'customers.access.customer': 'Customer', + 'customers.access.manager': 'Manager', + 'customers.access.role.admin': 'Admin', + 'customers.access.role.editor': 'Editor', + 'customers.access.role.viewer': 'Viewer', + 'customers.access.hintSuperAdmin': + 'Admin can manage other managers (except other admins). Editor and viewer manage content only.', + 'customers.access.hintBusiness': + 'Managers can be Editor or Viewer. Only a super admin can assign Admin.', + 'customers.access.cancel': 'Cancel', + 'customers.access.save': 'Save', + 'customers.access.error': 'Unable to update access.', + 'customers.access.change': 'Change access', + 'customers.access.ownerLocked': 'Business owner cannot be changed', + 'customers.access.adminLocked': 'Only a super admin can change an admin', + 'customers.access.badge.owner': 'Owner', + 'customers.access.badge.admin': 'Admin', + 'customers.access.badge.editor': 'Editor', + 'customers.access.badge.viewer': 'Viewer', + 'customers.access.badge.manager': 'Manager', + 'customers.toast.accessUpdated': 'Access updated to {role}.', 'blog.page.subtitle': 'Create and manage blog posts and categories.', 'blog.card.list.desc': 'View, edit and manage all your blog posts.', @@ -806,7 +870,9 @@ const en = { 'blog.list.subtitle': '{count} posts · View, edit and manage your blog content.', 'blog.list.loading': 'Loading blog posts...', - 'blog.list.empty': 'No blog posts found.', + 'blog.list.empty': 'No blog posts yet.', + 'blog.list.emptyHint': 'Share news, stories, and updates with your audience.', + 'blog.list.emptyCta': 'Write your first post', 'blog.list.errorLoad': 'Unable to load blog posts.', 'blog.list.errorDelete': 'Unable to delete blog post.', 'blog.list.errorVerify': 'Unable to update blog verification.', @@ -1710,6 +1776,41 @@ const fa: Record = { 'products.card.remove': 'حذف محصول', 'products.card.draft': 'پیش‌نویس', + 'products.details.loading': 'در حال بارگذاری محصول...', + 'products.details.notFound': 'محصول یافت نشد.', + 'products.details.errorLoad': 'بارگذاری محصول ممکن نشد.', + 'products.details.back': 'بازگشت به محصولات من', + 'products.details.openGallery': 'باز کردن گالری تصاویر', + 'products.details.noImage': 'بدون تصویر', + 'products.details.viewImage': 'مشاهده تصویر {index}', + 'products.details.tabsAria': 'جزئیات محصول', + 'products.details.tab.technical': 'اطلاعات فنی', + 'products.details.tab.comments': 'نظرات', + 'products.details.tab.reviews': 'بررسی کارشناسان', + 'products.details.technical.loading': 'در حال بارگذاری اطلاعات فنی...', + 'products.details.technical.noForm': 'برای این دسته‌بندی فرم فنی تعریف نشده است.', + 'products.details.technical.noData': 'هنوز اطلاعات فنی برای این محصول ثبت نشده است.', + 'products.details.technical.noCategory': 'این محصول به دسته‌بندی اختصاص داده نشده است.', + 'products.details.technical.empty': 'اطلاعات فنی موجود نیست.', + 'products.details.technical.errorLoad': 'بارگذاری اطلاعات فنی ممکن نشد.', + 'products.details.comments.loading': 'در حال بارگذاری نظرات...', + 'products.details.comments.empty': 'هنوز نظری برای این محصول ثبت نشده است.', + 'products.details.comments.errorLoad': 'بارگذاری نظرات ممکن نشد.', + 'products.details.comments.errorApprove': 'به‌روزرسانی تأیید نظر ممکن نشد.', + 'products.details.comments.errorDelete': 'حذف نظر ممکن نشد.', + 'products.details.reviews.loading': 'در حال بارگذاری بررسی کارشناسان...', + 'products.details.reviews.empty': 'هنوز بررسی کارشناسی ثبت نشده است.', + 'products.details.reviews.errorLoad': 'بارگذاری بررسی کارشناسان ممکن نشد.', + 'products.details.reviews.errorApprove': 'به‌روزرسانی تأیید بررسی ممکن نشد.', + 'products.details.reviews.errorDelete': 'حذف بررسی کارشناسی ممکن نشد.', + 'products.details.pending': 'در انتظار تأیید', + 'products.details.approve': 'تأیید', + 'products.details.reject': 'رد', + 'products.details.remove': 'حذف', + 'products.details.positivePoints': 'نقاط قوت', + 'products.details.negativePoints': 'نقاط ضعف', + 'products.details.ratingAria': 'امتیاز {rate} از ۱۰', + 'products.form.errorOptions': 'بارگذاری گزینه‌های فرم ممکن نشد.', 'products.form.errorSave': 'ذخیره محصول ممکن نشد.', 'products.form.thumbnail': 'تصویر بندانگشتی', @@ -2038,9 +2139,13 @@ const fa: Record = { 'customers.filters': 'فیلترها', 'customers.filter.name': 'جستجو بر اساس نام', 'customers.filter.cell': 'شماره موبایل', + 'customers.filter.access': 'نقش کاربر', + 'customers.filter.access.all': 'تمام کاربران', + 'customers.filter.access.customers': 'مشتریان', + 'customers.filter.access.managers': 'مدیران سیستم', 'customers.search': 'جستجو', 'customers.clearFilters': 'پاک کردن فیلترها', - 'customers.listTitle': 'فهرست مشتریان', + 'customers.listTitle': 'فهرست کاربران', 'customers.showing': 'نمایش {from} تا {to} از {total}', 'customers.none': 'بدون مشتری', 'customers.empty': 'نتیجه‌ای یافت نشد.', @@ -2095,6 +2200,29 @@ const fa: Record = { 'customers.addModal.error': 'افزودن مشتری ممکن نشد.', 'customers.addModal.errorPasswordMatch': 'رمزهای عبور یکسان نیستند.', 'customers.addModal.errorPasswordLength': 'رمز عبور باید حداقل ۸ کاراکتر باشد.', + 'customers.access.title': 'تغییر دسترسی', + 'customers.access.subtitle': 'دسترسی مشتری یا مدیر را برای {name} انتخاب کنید.', + 'customers.access.customer': 'مشتری', + 'customers.access.manager': 'مدیر', + 'customers.access.role.admin': 'ادمین', + 'customers.access.role.editor': 'ویرایشگر', + 'customers.access.role.viewer': 'بیننده', + 'customers.access.hintSuperAdmin': + 'ادمین می‌تواند مدیران دیگر را مدیریت کند (به‌جز ادمین‌های دیگر). ویرایشگر و بیننده فقط محتوا را مدیریت می‌کنند.', + 'customers.access.hintBusiness': + 'مدیران می‌توانند ویرایشگر یا بیننده باشند. فقط سوپرادمین می‌تواند ادمین تعیین کند.', + 'customers.access.cancel': 'انصراف', + 'customers.access.save': 'ذخیره', + 'customers.access.error': 'به‌روزرسانی دسترسی ممکن نشد.', + 'customers.access.change': 'تغییر دسترسی', + 'customers.access.ownerLocked': 'نقش صاحب کسب‌وکار قابل تغییر نیست', + 'customers.access.adminLocked': 'فقط سوپرادمین می‌تواند ادمین را تغییر دهد', + 'customers.access.badge.owner': 'صاحب', + 'customers.access.badge.admin': 'ادمین', + 'customers.access.badge.editor': 'ویرایشگر', + 'customers.access.badge.viewer': 'بیننده', + 'customers.access.badge.manager': 'مدیر', + 'customers.toast.accessUpdated': 'دسترسی به {role} به‌روزرسانی شد.', 'blog.page.subtitle': 'مطالب و دسته‌بندی‌های بلاگ را ایجاد و مدیریت کنید.', 'blog.card.list.desc': 'همه مطالب بلاگ را مشاهده، ویرایش و مدیریت کنید.', @@ -2109,7 +2237,9 @@ const fa: Record = { 'blog.list.subtitle': '{count} مطلب · مشاهده، ویرایش و مدیریت محتوای بلاگ.', 'blog.list.loading': 'در حال بارگذاری مطالب بلاگ...', - 'blog.list.empty': 'مطلب بلاگی یافت نشد.', + 'blog.list.empty': 'هنوز مطلب بلاگی ندارید.', + 'blog.list.emptyHint': 'اخبار، داستان‌ها و به‌روزرسانی‌ها را با مخاطبان خود به اشتراک بگذارید.', + 'blog.list.emptyCta': 'اولین مطلب را بنویسید', 'blog.list.errorLoad': 'بارگذاری مطالب بلاگ ممکن نشد.', 'blog.list.errorDelete': 'حذف مطلب بلاگ ممکن نشد.', 'blog.list.errorVerify': 'به‌روزرسانی وضعیت تأیید بلاگ ممکن نشد.', diff --git a/apps/business/src/pages/BlogListPage.tsx b/apps/business/src/pages/BlogListPage.tsx index be97066..a8f434c 100644 --- a/apps/business/src/pages/BlogListPage.tsx +++ b/apps/business/src/pages/BlogListPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Plus } from 'lucide-react' +import { FileText, Plus } from 'lucide-react' import { Breadcrumbs } from '../components/Breadcrumbs' import { BlogCard } from '../components/BlogCard' import { BlogCommentsModal } from '../components/BlogCommentsModal' @@ -180,7 +180,21 @@ export function BlogListPage() { {isLoading ? (

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

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

      {t('blog.list.empty')}

      +
      + +

      {t('blog.list.empty')}

      +

      {t('blog.list.emptyHint')}

      + +
      ) : ( <>
      diff --git a/apps/business/src/pages/BlogPage.module.css b/apps/business/src/pages/BlogPage.module.css index eb26e0f..0455ef9 100644 --- a/apps/business/src/pages/BlogPage.module.css +++ b/apps/business/src/pages/BlogPage.module.css @@ -8,6 +8,82 @@ border-radius: var(--radius); } +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 56px 28px; + text-align: center; + background: var(--glass-bg); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + box-shadow: var(--glass-shadow); +} + +.emptyIcon { + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 6px; + border-radius: 16px; + color: var(--primary); + background: linear-gradient( + 145deg, + rgba(255, 255, 255, 0.72) 0%, + rgba(var(--primary-rgb) / 0.12) 100% + ); + border: 1px solid rgba(255, 255, 255, 0.45); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06); +} + +.emptyTitle { + margin: 0; + font-size: 17px; + font-weight: 700; + color: var(--text-primary); + line-height: 1.4; +} + +.emptyHint { + margin: 0 0 8px; + max-width: 360px; + font-size: 13px; + line-height: 1.65; + color: var(--text-secondary); +} + +.emptyCta { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 42px; + padding: 10px 20px; + font-size: 14px; + font-weight: 600; + color: #fff; + text-align: center; + background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); + border-radius: 999px; + box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.32); + transition: transform 0.2s, box-shadow 0.2s; +} + +button.emptyCta { + text-align: center; +} + +.emptyCta:hover { + transform: translateY(-1px); + box-shadow: 0 12px 26px rgba(var(--primary-rgb) / 0.4); +} + .error { margin-bottom: 16px; padding: 12px 14px; diff --git a/apps/business/src/pages/CustomerProductDetailsPage.module.css b/apps/business/src/pages/CustomerProductDetailsPage.module.css index 582e484..9786c87 100644 --- a/apps/business/src/pages/CustomerProductDetailsPage.module.css +++ b/apps/business/src/pages/CustomerProductDetailsPage.module.css @@ -126,12 +126,14 @@ } .badge[data-status='draft'] { - color: #fff; + color: #0f172a; background: linear-gradient( 145deg, - rgba(251, 191, 36, 0.55) 0%, - rgba(245, 158, 11, 0.32) 100% + rgba(255, 255, 255, 0.78) 0%, + rgba(255, 255, 255, 0.42) 100% ); + border-color: rgba(255, 255, 255, 0.55); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } .badge[data-status='published'] { diff --git a/apps/business/src/pages/CustomersPage.module.css b/apps/business/src/pages/CustomersPage.module.css index 0b0d55d..e1b15af 100644 --- a/apps/business/src/pages/CustomersPage.module.css +++ b/apps/business/src/pages/CustomersPage.module.css @@ -1,3 +1,11 @@ +.pageHeaderCompact { + margin-bottom: 12px; +} + +.pageHeaderCompact :global(h2) { + margin-bottom: 0; +} + .tablePanel { margin-top: 12px; padding: 0; @@ -61,7 +69,7 @@ } .colActions { - width: 206px; + width: 240px; } .th, @@ -132,10 +140,9 @@ align-items: center; justify-content: flex-end; gap: 0; - direction: ltr; } -/* FA: عملیات column is on the physical left — pin controls to that edge. */ +/* FA: عملیات column is on the physical left. */ .tableFa .thActions, .tableFa .tdActions { text-align: left; @@ -143,12 +150,8 @@ padding-right: 10px; } -.tableFa .rowActions { - justify-content: flex-start; -} - .toggleInActions { - margin-right: 10px; + margin-inline-end: 10px; flex-shrink: 0; display: flex; align-items: center; diff --git a/apps/business/src/pages/CustomersPage.tsx b/apps/business/src/pages/CustomersPage.tsx index d2088b3..b71cd8e 100644 --- a/apps/business/src/pages/CustomersPage.tsx +++ b/apps/business/src/pages/CustomersPage.tsx @@ -1,28 +1,33 @@ import { useEffect, useMemo, useState } from 'react' -import { MessageSquare, Pencil, Plus, RotateCcw, Search, Ticket, Trash2 } from 'lucide-react' +import { MessageSquare, Pencil, Plus, RotateCcw, Search, Shield, Ticket, Trash2 } from 'lucide-react' import { useLocale } from '@meshkee/dashboard-ui' import { AddCustomerModal } from '../components/AddCustomerModal' import { Breadcrumbs } from '../components/Breadcrumbs' +import { ChangeUserAccessModal } from '../components/ChangeUserAccessModal' import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal' import { EditCustomerModal } from '../components/EditCustomerModal' import { Pagination } from '../components/Pagination' import { ToggleSwitch } from '../components/ToggleSwitch' import { Tooltip } from '../components/Tooltip' +import { useAuth } from '../context/AuthContext' import { useToast } from '../context/ToastContext' import { useT } from '../i18n/useT' import { ApiError, isAbortError } from '../lib/api' +import { getActiveBusinessId } from '../lib/businessContext' import { formatCellForDisplay } from '../lib/cellNumber' import { listCustomers, removeCustomer, updateCustomerEnabled, type BusinessCustomerListItem, + type CustomerAccessFilter, type CustomersListResponse, } from '../services/customerService' import { formatIrtPrice } from '../utils/irtPrice' import { textLocaleAttrs } from '../utils/textLocale' import filterStyles from '../components/ListFiltersPanel.module.css' import pageStyles from '../components/PageContent.module.css' +import accessStyles from '../components/ChangeUserAccessModal.module.css' import styles from './CustomersPage.module.css' const PAGE_SIZE = 24 @@ -47,26 +52,66 @@ function formatTransactionTotal(total: number | null | undefined) { return formatIrtPrice(total) } +function accessBadgeKey(user: BusinessCustomerListItem): string | null { + if (user.isBusinessOwner) return 'customers.access.badge.owner' + if (user.teamRole === 'admin') return 'customers.access.badge.admin' + if (user.teamRole === 'editor') return 'customers.access.badge.editor' + if (user.teamRole === 'viewer') return 'customers.access.badge.viewer' + if (user.businessMemberId) return 'customers.access.badge.manager' + return null +} + export function CustomersPage() { const t = useT() const { locale } = useLocale() + const { user: authUser } = useAuth() const { showToast } = useToast() const [data, setData] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState('') - const [appliedFilters, setAppliedFilters] = useState<{ name?: string; cellNumber?: string }>({}) + const [appliedFilters, setAppliedFilters] = useState<{ + name?: string + cellNumber?: string + access: CustomerAccessFilter + }>({ access: 'all' }) const [page, setPage] = useState(1) const [draftName, setDraftName] = useState('') const [draftCell, setDraftCell] = useState('') + const [draftAccess, setDraftAccess] = useState('all') const [togglingId, setTogglingId] = useState(null) const [editTarget, setEditTarget] = useState(null) + const [accessTarget, setAccessTarget] = useState(null) const [createOpen, setCreateOpen] = useState(false) const [removeTarget, setRemoveTarget] = useState(null) const [removing, setRemoving] = useState(false) + const membership = useMemo(() => { + const activeId = getActiveBusinessId() + if (!authUser?.businesses.length) return undefined + return ( + authUser.businesses.find((b) => String(b.id) === String(activeId)) ?? + authUser.businesses[0] + ) + }, [authUser]) + const isSuperAdmin = Boolean( + authUser?.isSuperAdmin || authUser?.roles.includes('super_admin'), + ) + const canManageAccess = + isSuperAdmin || + Boolean(membership?.isOwner) || + Boolean(membership?.permissions.includes('business.team.update')) + + function accessChangeDisabledReason(customer: BusinessCustomerListItem): string | null { + if (customer.isBusinessOwner) return t('customers.access.ownerLocked') + if (customer.teamRole === 'admin' && !isSuperAdmin) { + return t('customers.access.adminLocked') + } + return null + } + useEffect(() => { const controller = new AbortController() @@ -97,7 +142,7 @@ export function CustomersPage() { return () => { controller.abort() } - }, [page, appliedFilters.name, appliedFilters.cellNumber, t]) + }, [page, appliedFilters.name, appliedFilters.cellNumber, appliedFilters.access, t]) const totalPages = useMemo(() => { const total = data?.total ?? 0 @@ -117,6 +162,7 @@ export function CustomersPage() { function applyFilters() { setPage(1) setAppliedFilters({ + access: draftAccess, ...(draftName.trim() ? { name: draftName.trim() } : {}), ...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}), }) @@ -125,8 +171,19 @@ export function CustomersPage() { function clearFilters() { setDraftName('') setDraftCell('') + setDraftAccess('all') setPage(1) - setAppliedFilters({}) + setAppliedFilters({ access: 'all' }) + } + + function handleAccessFilterChange(value: CustomerAccessFilter) { + setDraftAccess(value) + setPage(1) + setAppliedFilters({ + access: value, + ...(draftName.trim() ? { name: draftName.trim() } : {}), + ...(draftCell.trim() ? { cellNumber: draftCell.trim() } : {}), + }) } async function handleToggleEnabled(customer: BusinessCustomerListItem, isEnabled: boolean) { @@ -201,6 +258,22 @@ export function CustomersPage() { showToast(t('customers.toast.updated', { name: displayName(updated) }), 'success') } + function handleAccessSaved(updated: BusinessCustomerListItem) { + setData((prev) => { + if (!prev) return prev + return { + ...prev, + items: prev.items.map((item) => (item.id === updated.id ? { ...item, ...updated } : item)), + } + }) + const roleLabel = updated.isBusinessOwner + ? t('customers.access.badge.owner') + : updated.teamRole + ? t(`customers.access.role.${updated.teamRole}`) + : t('customers.access.customer') + showToast(t('customers.toast.accessUpdated', { role: roleLabel }), 'success') + } + function handleCustomerCreated(customer: BusinessCustomerListItem) { setPage(1) setData((prev) => { @@ -234,17 +307,28 @@ export function CustomersPage() { ]} /> -
      +

      {t('customers.title')}

      -

      {t('customers.subtitle')}

      -
      {t('customers.filters')}
      { e.preventDefault(); applyFilters() }}>
      +
      + +
      { const name = displayName(customer) const nameLocale = textLocaleAttrs(name) + const badgeKey = accessBadgeKey(customer) + const accessLocked = accessChangeDisabledReason(customer) return ( {name}
      + {badgeKey ? ( + {t(badgeKey)} + ) : null} {!customer.isEnabled && (
      {t('customers.disabled')}
      )} @@ -417,6 +506,19 @@ export function CustomersPage() { /> + {canManageAccess ? ( + + + + ) : null} {galleryImages.length > 1 && ( @@ -126,7 +144,7 @@ export function ProductDetailsPage() { setActiveIndex(index) openLightbox(index) }} - aria-label={`View image ${index + 1}`} + aria-label={t('products.details.viewImage', { index: index + 1 })} > @@ -147,21 +165,21 @@ export function ProductDetailsPage() { )}

      - {product.nameEn} + {primaryName}

      - {product.nameFa && ( + {secondaryName ? (

      - {product.nameFa} + {secondaryName}

      - )} + ) : null} {product.summary && (

      setLightboxOpen(false)} /> diff --git a/apps/business/src/services/customerService.ts b/apps/business/src/services/customerService.ts index 0e70af2..89971cd 100644 --- a/apps/business/src/services/customerService.ts +++ b/apps/business/src/services/customerService.ts @@ -17,6 +17,10 @@ export interface BusinessCustomerListItem extends BusinessCustomer { orderCount?: number | null /** Placeholder until orders stats API exists */ totalTransactionsIrt?: number | null + /** Present when this customer is also staff/owner on the business. */ + businessMemberId?: string | null + isBusinessOwner?: boolean + teamRole?: string | null } export interface CustomersListResponse { @@ -26,11 +30,14 @@ export interface CustomersListResponse { pageSize: number } +export type CustomerAccessFilter = 'all' | 'customers' | 'managers' + export interface ListCustomersParams { page?: number pageSize?: number name?: string cellNumber?: string + access?: CustomerAccessFilter } function businessPath(suffix = '') { @@ -50,6 +57,7 @@ export async function listCustomers( if (params.pageSize !== undefined) q.set('pageSize', String(params.pageSize)) if (params.name) q.set('name', params.name) if (params.cellNumber) q.set('cellNumber', params.cellNumber) + if (params.access) q.set('access', params.access) const query = q.toString() const path = `${businessPath()}${query ? `?${query}` : ''}` diff --git a/apps/business/src/services/teamService.ts b/apps/business/src/services/teamService.ts new file mode 100644 index 0000000..b216ae5 --- /dev/null +++ b/apps/business/src/services/teamService.ts @@ -0,0 +1,44 @@ +import { apiRequest } from '../lib/api' +import { getActiveBusinessId } from '../lib/businessContext' + +export type BusinessAccess = 'customer' | 'staff' + +export interface AssignBusinessAccessPayload { + userId: string + access: BusinessAccess + roleSlug?: string +} + +export interface AssignBusinessAccessResult { + message: string + access: BusinessAccess + member: { + id: string | number + userId: string | number + isOwner: boolean + teamRole: string | null + } | null +} + +function teamPath(suffix = '') { + const businessId = getActiveBusinessId() + if (!businessId) { + throw new Error('No active business selected. Please sign in again.') + } + return `/businesses/${businessId}/team${suffix}` +} + +/** Set customer vs manager (admin/editor/viewer) for a user on this business. */ +export async function assignBusinessAccess(payload: AssignBusinessAccessPayload) { + return apiRequest(teamPath('/access'), { + method: 'PATCH', + auth: true, + body: { + userId: String(payload.userId), + access: payload.access, + ...(payload.access === 'staff' && payload.roleSlug + ? { roleSlug: payload.roleSlug } + : {}), + }, + }) +} diff --git a/apps/customer/src/components/Header.module.css b/apps/customer/src/components/Header.module.css index 56713b6..18883c9 100644 --- a/apps/customer/src/components/Header.module.css +++ b/apps/customer/src/components/Header.module.css @@ -3,13 +3,14 @@ align-items: center; justify-content: space-between; padding: 16px 32px; - background: var(--glass-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-bottom: 1px solid var(--glass-border); position: sticky; top: 0; z-index: 50; + background: color-mix(in srgb, #ffffff 42%, transparent); + backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px)); + -webkit-backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px)); + border-bottom: 1px solid var(--glass-border); + transform: translateZ(0); } .left { diff --git a/apps/customer/src/components/UserProductCard.module.css b/apps/customer/src/components/UserProductCard.module.css index b4bb62b..6bf4284 100644 --- a/apps/customer/src/components/UserProductCard.module.css +++ b/apps/customer/src/components/UserProductCard.module.css @@ -66,13 +66,14 @@ } .badge[data-status='draft'] { - color: #fff; + color: #0f172a; background: linear-gradient( 145deg, - rgba(251, 191, 36, 0.55) 0%, - rgba(245, 158, 11, 0.32) 100% + rgba(255, 255, 255, 0.78) 0%, + rgba(255, 255, 255, 0.42) 100% ); - border-color: rgba(251, 191, 36, 0.35); + border-color: rgba(255, 255, 255, 0.55); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } .badge[data-status='published'] { diff --git a/apps/customer/src/pages/LoginPage.module.css b/apps/customer/src/pages/LoginPage.module.css index 5e313ea..67c2cca 100644 --- a/apps/customer/src/pages/LoginPage.module.css +++ b/apps/customer/src/pages/LoginPage.module.css @@ -384,6 +384,7 @@ p.footerText { line-height: 1.75; color: var(--text-primary); margin-bottom: 18px; + text-align: justify; } .noticeLink { @@ -403,6 +404,7 @@ p.footerText { padding: 11px 16px; font-size: 14px; font-weight: 600; + text-align: center; color: #fff; background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); border-radius: var(--radius-sm); @@ -410,6 +412,10 @@ p.footerText { transition: transform 0.2s, opacity 0.2s; } +button.noticeAdmit { + text-align: center; +} + .noticeAdmit:hover:not(:disabled) { transform: translateY(-1px); } diff --git a/apps/customer/src/pages/MyProductDetailsPage.module.css b/apps/customer/src/pages/MyProductDetailsPage.module.css index b206e36..d9411fd 100644 --- a/apps/customer/src/pages/MyProductDetailsPage.module.css +++ b/apps/customer/src/pages/MyProductDetailsPage.module.css @@ -108,12 +108,14 @@ } .badge[data-status='draft'] { - color: #fff; + color: #0f172a; background: linear-gradient( 145deg, - rgba(251, 191, 36, 0.55) 0%, - rgba(245, 158, 11, 0.32) 100% + rgba(255, 255, 255, 0.78) 0%, + rgba(255, 255, 255, 0.42) 100% ); + border-color: rgba(255, 255, 255, 0.55); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.08); } .badge[data-status='published'] { diff --git a/apps/super-admin/src/components/Header.module.css b/apps/super-admin/src/components/Header.module.css index 4da2d77..6d8c7f4 100644 --- a/apps/super-admin/src/components/Header.module.css +++ b/apps/super-admin/src/components/Header.module.css @@ -3,13 +3,14 @@ align-items: center; justify-content: space-between; padding: 12px 24px; - background: var(--glass-bg); - backdrop-filter: blur(var(--blur-glass)); - -webkit-backdrop-filter: blur(var(--blur-glass)); - border-bottom: 1px solid var(--glass-border); position: sticky; top: 0; z-index: 50; + background: color-mix(in srgb, #ffffff 42%, transparent); + backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px)); + -webkit-backdrop-filter: saturate(180%) blur(var(--blur-glass, 28px)); + border-bottom: 1px solid var(--glass-border); + transform: translateZ(0); } .left { diff --git a/apps/super-admin/src/components/RichTextEditor.module.css b/apps/super-admin/src/components/RichTextEditor.module.css index 463fddd..d8a323b 100644 --- a/apps/super-admin/src/components/RichTextEditor.module.css +++ b/apps/super-admin/src/components/RichTextEditor.module.css @@ -73,3 +73,11 @@ .editor p:last-child { margin-bottom: 0; } + +.editor blockquote { + margin: 0.35em 0; + margin-inline-start: 1.4em; + margin-inline-end: 0; + padding: 0; + border: 0; +} diff --git a/apps/super-admin/src/components/RichTextEditor.tsx b/apps/super-admin/src/components/RichTextEditor.tsx index 427b802..d357f6a 100644 --- a/apps/super-admin/src/components/RichTextEditor.tsx +++ b/apps/super-admin/src/components/RichTextEditor.tsx @@ -1,7 +1,18 @@ import { useCallback, useEffect, useRef } from 'react' -import { Bold, Italic, List, ListOrdered, Underline } from 'lucide-react' +import { + Bold, + IndentDecrease, + IndentIncrease, + Italic, + List, + ListOrdered, + Underline, +} from 'lucide-react' import styles from './RichTextEditor.module.css' +const INDENT_STEP_PX = 24 +const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4']) + interface RichTextEditorProps { value: string onChange: (value: string) => void @@ -36,6 +47,51 @@ export function RichTextEditor({ syncChange() } + function findIndentBlock(editor: HTMLElement): HTMLElement | null { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return null + + let node: Node | null = selection.anchorNode + while (node && node !== editor) { + if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) { + return node + } + node = node.parentNode + } + return null + } + + function changeIndent(direction: 1 | -1) { + const editor = editorRef.current + if (!editor) return + + editor.focus() + const block = findIndentBlock(editor) + if (!block) { + document.execCommand(direction > 0 ? 'indent' : 'outdent') + syncChange() + return + } + + const current = + Number.parseFloat(block.style.paddingInlineStart) || + Number.parseFloat(getComputedStyle(block).paddingInlineStart) || + 0 + const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX)) + if (next === 0) { + block.style.paddingInlineStart = '' + } else { + block.style.paddingInlineStart = `${next}px` + } + syncChange() + } + + function onEditorKeyDown(e: React.KeyboardEvent) { + if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return + e.preventDefault() + changeIndent(e.shiftKey ? -1 : 1) + } + return (

      @@ -70,6 +126,23 @@ export function RichTextEditor({ > + + +
      diff --git a/apps/super-admin/src/pages/UsersPage.module.css b/apps/super-admin/src/pages/UsersPage.module.css index c7972d9..60ae014 100644 --- a/apps/super-admin/src/pages/UsersPage.module.css +++ b/apps/super-admin/src/pages/UsersPage.module.css @@ -80,7 +80,15 @@ .roleHint { margin-top: 12px; + margin-bottom: 8px; font-size: 12px; color: var(--text-muted); line-height: 1.45; } + +.roleListNested { + margin-top: 4px; + margin-inline-start: 12px; + padding-inline-start: 10px; + border-inline-start: 2px solid rgba(var(--primary-rgb) / 0.2); +} diff --git a/apps/super-admin/src/pages/UsersPage.tsx b/apps/super-admin/src/pages/UsersPage.tsx index ee579c2..ecbfb40 100644 --- a/apps/super-admin/src/pages/UsersPage.tsx +++ b/apps/super-admin/src/pages/UsersPage.tsx @@ -8,9 +8,9 @@ import type { RoleOption, UserListItem, UsersListResponse } from '../types/user' import type { ListUsersParams } from '../services/userService' import { ApiError, isAbortError } from '../lib/api' import { - addTeamMember, + assignBusinessAccess, listTeamRoles, - updateTeamMemberRole, + type BusinessAccess, } from '../services/teamService' import { adminResetUserPassword, @@ -41,14 +41,45 @@ function displayName(user: UserListItem) { return name || '—' } +const TEAM_ROLE_ORDER = ['admin', 'editor', 'viewer'] as const + +const TEAM_ROLE_LABELS: Record = { + admin: 'Admin', + editor: 'Editor', + viewer: 'Viewer', +} + +function teamRoleLabel(slug: string | null | undefined, teamRoles: RoleOption[]) { + if (!slug) return null + return ( + TEAM_ROLE_LABELS[slug] ?? + teamRoles.find((role) => role.slug === slug)?.name ?? + slug + ) +} + function formatGlobalRoleLabel(user: UserListItem, teamRoles: RoleOption[]) { + if (user.isBusinessOwner) return 'Business Owner' + if (user.teamRole) { + const teamName = teamRoleLabel(user.teamRole, teamRoles) + return teamName ? `Manager · ${teamName}` : 'Manager' + } if (user.roleSlug === 'business_staff' && user.teamRole) { - const teamName = teamRoles.find((role) => role.slug === user.teamRole)?.name ?? user.teamRole - return `Business Staff · ${teamName}` + const teamName = teamRoleLabel(user.teamRole, teamRoles) + return teamName ? `Manager · ${teamName}` : 'Manager' } return user.roles } +function formatBusinessUserRoleLabel(user: UserListItem, teamRoles: RoleOption[]) { + if (user.isBusinessOwner) return 'Business Owner' + if (user.businessMemberId && user.teamRole) { + return teamRoleLabel(user.teamRole, teamRoles) ?? 'Manager' + } + if (user.businessMemberId) return 'Manager' + return 'Customer' +} + export function UsersPage() { const { showToast } = useToast() const navigate = useNavigate() @@ -93,7 +124,8 @@ export function UsersPage() { const [roleOpen, setRoleOpen] = useState(false) const [roleUser, setRoleUser] = useState(null) const [selectedRoleSlug, setSelectedRoleSlug] = useState('') - const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('') + const [businessAccess, setBusinessAccess] = useState('customer') + const [selectedTeamRoleSlug, setSelectedTeamRoleSlug] = useState('admin') const [roleSubmitting, setRoleSubmitting] = useState(false) const [roleError, setRoleError] = useState('') @@ -259,84 +291,111 @@ export function UsersPage() { createLastName.trim().length >= 2 && toE164CellNumber(createCell.trim()).length > 0 + const orderedTeamRoles = useMemo(() => { + const bySlug = new Map(teamRoles.map((role) => [role.slug, role])) + const ordered = TEAM_ROLE_ORDER.map((slug) => bySlug.get(slug)).filter( + (role): role is RoleOption => Boolean(role), + ) + const extras = teamRoles.filter( + (role) => !TEAM_ROLE_ORDER.includes(role.slug as (typeof TEAM_ROLE_ORDER)[number]), + ) + return [...ordered, ...extras] + }, [teamRoles]) + function openRoleChange(user: UserListItem) { + if (businessFilter && user.isBusinessOwner) return + setRoleUser(user) setSelectedRoleSlug(user.roleSlug ?? '') - setSelectedTeamRoleSlug(user.teamRole ?? teamRoles[0]?.slug ?? '') + if (businessFilter) { + const isStaff = Boolean(user.businessMemberId) && !user.isBusinessOwner + setBusinessAccess(isStaff ? 'staff' : 'customer') + setSelectedTeamRoleSlug( + (isStaff && user.teamRole) || orderedTeamRoles[0]?.slug || 'admin', + ) + } else { + setBusinessAccess('customer') + setSelectedTeamRoleSlug(orderedTeamRoles[0]?.slug || 'admin') + } setRoleError('') setRoleOpen(true) } - const needsTeamRole = - selectedRoleSlug === 'business_staff' && - businessFilter !== null && - !roleUser?.isBusinessOwner - - const canSaveRole = - Boolean(selectedRoleSlug) && (!needsTeamRole || Boolean(selectedTeamRoleSlug)) + const canSaveRole = businessFilter + ? !roleUser?.isBusinessOwner && + (businessAccess === 'customer' || Boolean(selectedTeamRoleSlug)) + : Boolean(selectedRoleSlug) async function submitRoleChange() { - if (!roleUser || !selectedRoleSlug || !canSaveRole) return + if (!roleUser || !canSaveRole) return setRoleSubmitting(true) setRoleError('') try { - await updateUserRole(roleUser.id, selectedRoleSlug) + if (businessFilter) { + const result = await assignBusinessAccess(businessFilter.businessId, { + userId: roleUser.id, + access: businessAccess, + roleSlug: businessAccess === 'staff' ? selectedTeamRoleSlug : undefined, + }) - let nextTeamRole = roleUser.teamRole - let nextMemberId = roleUser.businessMemberId + const accessLabel = + businessAccess === 'staff' + ? teamRoleLabel(selectedTeamRoleSlug, teamRoles) ?? 'Manager' + : 'Customer' - if (needsTeamRole && businessFilter && selectedTeamRoleSlug) { - if (roleUser.businessMemberId) { - await updateTeamMemberRole( - businessFilter.businessId, - roleUser.businessMemberId, - selectedTeamRoleSlug, - ) - nextTeamRole = selectedTeamRoleSlug - } else { - const result = (await addTeamMember(businessFilter.businessId, { - cellNumber: roleUser.cellNumber, - firstName: roleUser.firstName ?? 'User', - lastName: roleUser.lastName ?? 'User', - roleSlug: selectedTeamRoleSlug, - })) as { member?: { id: number; teamRole: string } } - nextTeamRole = result.member?.teamRole ?? selectedTeamRoleSlug - nextMemberId = result.member?.id ?? null - } + showToast(`Access updated to ${accessLabel}.`, 'success') + + setData((prev) => { + if (!prev) return prev + return { + ...prev, + items: prev.items.map((item) => + item.id === roleUser.id + ? { + ...item, + roleSlug: businessAccess === 'staff' ? 'business_staff' : 'customer', + roles: accessLabel, + teamRole: businessAccess === 'staff' ? selectedTeamRoleSlug : null, + businessMemberId: + businessAccess === 'staff' + ? result.member?.id != null + ? Number(result.member.id) + : item.businessMemberId + : null, + isBusinessOwner: false, + } + : item, + ), + } + }) + } else { + await updateUserRole(roleUser.id, selectedRoleSlug) + + const roleName = + roles.find((role) => role.slug === selectedRoleSlug)?.name ?? selectedRoleSlug + + showToast(`Role updated to ${roleName}.`, 'success') + + setData((prev) => { + if (!prev) return prev + return { + ...prev, + items: prev.items.map((item) => + item.id === roleUser.id + ? { + ...item, + roleSlug: selectedRoleSlug, + roles: roleName, + } + : item, + ), + } + }) } - const roleName = roles.find((role) => role.slug === selectedRoleSlug)?.name ?? selectedRoleSlug - const teamName = - selectedRoleSlug === 'business_staff' && nextTeamRole - ? teamRoles.find((role) => role.slug === nextTeamRole)?.name ?? nextTeamRole - : null - - showToast( - teamName ? `Role updated to ${roleName} (${teamName}).` : `Role updated to ${roleName}.`, - 'success', - ) - - setData((prev) => { - if (!prev) return prev - return { - ...prev, - items: prev.items.map((item) => - item.id === roleUser.id - ? { - ...item, - roleSlug: selectedRoleSlug, - roles: teamName ? `${roleName} · ${teamName}` : roleName, - teamRole: selectedRoleSlug === 'business_staff' ? nextTeamRole : item.teamRole, - businessMemberId: - selectedRoleSlug === 'business_staff' ? nextMemberId : item.businessMemberId, - } - : item, - ), - } - }) setRoleOpen(false) setRoleUser(null) - setSelectedTeamRoleSlug('') + setSelectedTeamRoleSlug('admin') } catch (err) { setRoleError(err instanceof ApiError ? err.message : 'Unable to update role.') } finally { @@ -619,13 +678,16 @@ export function UsersPage() { {formatCellForDisplay(user.cellNumber)} - {user.roles ? ( - - {formatGlobalRoleLabel(user, teamRoles)} - - ) : ( - - )} + {(() => { + const label = businessFilter + ? formatBusinessUserRoleLabel(user, teamRoles) + : formatGlobalRoleLabel(user, teamRoles) + return label ? ( + {label} + ) : ( + + ) + })()} {user.businesses ?? } @@ -637,9 +699,17 @@ export function UsersPage() { type="button" className={tableStyles.controlBtn} onClick={() => openRoleChange(user)} - title="Change role" - aria-label="Change role" - disabled={!user.isActive} + title={ + businessFilter && user.isBusinessOwner + ? 'Business owner cannot be changed' + : 'Change role' + } + aria-label={ + businessFilter && user.isBusinessOwner + ? 'Business owner cannot be changed' + : 'Change role' + } + disabled={!user.isActive || Boolean(businessFilter && user.isBusinessOwner)} > @@ -901,7 +971,7 @@ export function UsersPage() { onClose={() => { setRoleOpen(false) setRoleUser(null) - setSelectedTeamRoleSlug('') + setSelectedTeamRoleSlug('admin') setRoleError('') }} > @@ -911,65 +981,87 @@ export function UsersPage() {

      ) : null}

      - Select a role for {roleUser ? displayName(roleUser) : 'user'}. - {businessFilter ? ` Permissions apply to ${businessFilter.businessName}.` : ''} + {businessFilter + ? `Choose customer or manager access for ${roleUser ? displayName(roleUser) : 'user'} on ${businessFilter.businessName}.` + : `Select a role for ${roleUser ? displayName(roleUser) : 'user'}.`}

      -
      - {roles.map((role) => ( - - ))} -
      - {selectedRoleSlug === 'business_staff' && businessFilter && !roleUser?.isBusinessOwner && ( + {businessFilter ? ( <> -
      -
      - - +
      + +
      + + {businessAccess === 'staff' ? ( + <> +

      + Only a super admin can assign Admin. Admins can assign Editor or Viewer. Admins + cannot change other admins. +

      +
      + {orderedTeamRoles.map((role) => ( + + ))} +
      + + ) : null} + ) : ( +
      + {roles.map((role) => ( + + ))} +
      )} - {selectedRoleSlug === 'business_staff' && !businessFilter && ( -

      - Open users from a business to assign team permissions (admin, editor, viewer). -

      - )} - - {selectedRoleSlug === 'business_staff' && roleUser?.isBusinessOwner && ( -

      - Business owners already have full access. Team permissions do not apply to owners. -

      - )}