Add a Statistics page for storefront visits and instruction 1:1/3:2 crops.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
d9bdfbbb12
commit
6be867ea7a
@@ -29,6 +29,13 @@ import { flattenCategories } from '../utils/categories'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './AddNewProductPage.module.css'
|
||||
|
||||
type CoverImageAspectId = '1:1' | '3:2'
|
||||
|
||||
const COVER_IMAGE_ASPECTS: { id: CoverImageAspectId; aspect: number }[] = [
|
||||
{ id: '1:1', aspect: 1 },
|
||||
{ id: '3:2', aspect: 3 / 2 },
|
||||
]
|
||||
|
||||
export function AddNewInstructionPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -45,6 +52,7 @@ export function AddNewInstructionPage() {
|
||||
const [videoProvider, setInstructionProvider] = useState<VideoProvider>('youtube')
|
||||
const [embedCode, setEmbedCode] = useState('')
|
||||
const [coverImage, setCoverImage] = useState<string | null>(null)
|
||||
const [coverImageAspect, setCoverImageAspect] = useState<CoverImageAspectId>('3:2')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
|
||||
const [loaded, setLoaded] = useState(!isEdit)
|
||||
@@ -52,6 +60,8 @@ export function AddNewInstructionPage() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const categoryOptions = flattenCategories(categories)
|
||||
const selectedAspect =
|
||||
COVER_IMAGE_ASPECTS.find((option) => option.id === coverImageAspect)?.aspect ?? 3 / 2
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -197,16 +207,44 @@ export function AddNewInstructionPage() {
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan4}`}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan4} ${styles.thumbnailField}`}>
|
||||
<label>{t('instruction.form.coverImage')}</label>
|
||||
<ImageCropper
|
||||
value={coverImage}
|
||||
onChange={setCoverImage}
|
||||
aspect={3 / 2}
|
||||
aspect={selectedAspect}
|
||||
uploadLabel={t('instruction.form.uploadCoverImage')}
|
||||
hint={t('instruction.form.cropHint')}
|
||||
hint={t('instruction.form.cropHint', { ratio: coverImageAspect })}
|
||||
changeLabel={t('instruction.form.changeCoverImage')}
|
||||
/>
|
||||
<div
|
||||
className={styles.aspectSwitch}
|
||||
role="radiogroup"
|
||||
aria-label={t('instruction.form.aspectAria')}
|
||||
>
|
||||
{COVER_IMAGE_ASPECTS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={coverImageAspect === option.id}
|
||||
className={`${styles.aspectSwitchBtn} ${
|
||||
coverImageAspect === option.id ? styles.aspectSwitchBtnSelected : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (option.id === coverImageAspect) return
|
||||
setCoverImageAspect(option.id)
|
||||
if (coverImage) {
|
||||
setCoverImage(null)
|
||||
setFeaturedMediaId(null)
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{option.id}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formColumn}>
|
||||
|
||||
@@ -289,26 +289,32 @@
|
||||
margin-top: 4px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: transparent;
|
||||
border: 1px solid #0a1628;
|
||||
border-radius: 50px;
|
||||
}
|
||||
|
||||
.aspectSwitchBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 52px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
font-family: var(--font-en), var(--font-ui);
|
||||
color: var(--text-secondary);
|
||||
color: var(--primary);
|
||||
background: transparent;
|
||||
border-radius: 50px;
|
||||
text-align: center;
|
||||
direction: ltr;
|
||||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.aspectSwitchBtn:hover:not(:disabled) {
|
||||
color: var(--primary);
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
}
|
||||
|
||||
.aspectSwitchBtn:disabled {
|
||||
@@ -324,6 +330,7 @@
|
||||
|
||||
.aspectSwitchBtnSelected:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.videoEmbedRow {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px 24px;
|
||||
margin-top: 12px;
|
||||
padding: 14px 18px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.row:first-of-type {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.rowTitle {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.rowName {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowTitleEn {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-en), var(--font-ui);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 76px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.chipValue {
|
||||
font-family: var(--font-en), var(--font-ui);
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.chipLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: inherit;
|
||||
opacity: 0.85;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 20px;
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.error + .row {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Breadcrumbs } from '../components/Breadcrumbs'
|
||||
import { HomeChartSlot } from '../components/HomeChartSlot'
|
||||
import { PageTitle } from '../components/PageTitle'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { useLocale } from '@meshkee/dashboard-ui'
|
||||
import { useT } from '../i18n/useT'
|
||||
import { translate, type BusinessMessageKey } from '../i18n/messages'
|
||||
import { ApiError } from '../lib/api'
|
||||
import {
|
||||
getViewSummary,
|
||||
type ViewPeriodCounts,
|
||||
type ViewSummaryGroupId,
|
||||
} from '../services/dailyActivityService'
|
||||
import {
|
||||
hasAnyBusinessModule,
|
||||
hasBusinessModule,
|
||||
isActiveHomeChart,
|
||||
type BusinessModuleId,
|
||||
} from '../utils/businessModules'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './StatisticsPage.module.css'
|
||||
|
||||
const EMPTY_COUNTS: ViewPeriodCounts = {
|
||||
today: 0,
|
||||
week: 0,
|
||||
month: 0,
|
||||
total: 0,
|
||||
}
|
||||
|
||||
const METRICS: {
|
||||
id: keyof ViewPeriodCounts
|
||||
labelKey: BusinessMessageKey
|
||||
}[] = [
|
||||
{ id: 'today', labelKey: 'statistics.metric.today' },
|
||||
{ id: 'week', labelKey: 'statistics.metric.week' },
|
||||
{ id: 'month', labelKey: 'statistics.metric.month' },
|
||||
{ id: 'total', labelKey: 'statistics.metric.total' },
|
||||
]
|
||||
|
||||
const STAT_ROWS: {
|
||||
id: ViewSummaryGroupId
|
||||
titleKey: BusinessMessageKey
|
||||
moduleId?: BusinessModuleId
|
||||
anyModuleIds?: BusinessModuleId[]
|
||||
}[] = [
|
||||
{ id: 'all', titleKey: 'statistics.row.all' },
|
||||
{ id: 'home', titleKey: 'statistics.row.home' },
|
||||
{
|
||||
id: 'products',
|
||||
titleKey: 'statistics.row.products',
|
||||
anyModuleIds: ['products', 'store'],
|
||||
},
|
||||
{ id: 'blog', titleKey: 'statistics.row.blog', moduleId: 'blog' },
|
||||
{ id: 'portfolio', titleKey: 'statistics.row.portfolio', moduleId: 'portfolio' },
|
||||
{ id: 'videos', titleKey: 'statistics.row.videos', moduleId: 'videos' },
|
||||
]
|
||||
|
||||
function formatCount(value: number) {
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
|
||||
export function StatisticsPage() {
|
||||
const t = useT()
|
||||
const { locale } = useLocale()
|
||||
const { enabledModules, homeCharts } = useTenantBranding()
|
||||
const [rows, setRows] = useState<Record<ViewSummaryGroupId, ViewPeriodCounts> | null>(
|
||||
null,
|
||||
)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const visibleCharts = homeCharts.filter(isActiveHomeChart)
|
||||
const visibleRows = STAT_ROWS.filter((row) => {
|
||||
if (row.anyModuleIds) {
|
||||
return hasAnyBusinessModule(enabledModules, row.anyModuleIds)
|
||||
}
|
||||
return !row.moduleId || hasBusinessModule(enabledModules, row.moduleId)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const data = await getViewSummary(controller.signal)
|
||||
setRows(data.rows)
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||
setError(err instanceof ApiError ? err.message : t('statistics.errorLoad'))
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => controller.abort()
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<main className={pageStyles.content}>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: t('nav.home'), href: '/' },
|
||||
{ label: t('title.statistics') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className={pageStyles.pageHeader}>
|
||||
<div>
|
||||
<PageTitle en="STATISTICS">{t('title.statistics')}</PageTitle>
|
||||
<p className={pageStyles.pageSubtitle}>{t('statistics.page.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleCharts.length > 0 ? (
|
||||
<div className={pageStyles.grid12}>
|
||||
{visibleCharts.map((chartId, index) => (
|
||||
<div key={`${chartId}-${index}`} className={pageStyles.col6}>
|
||||
<HomeChartSlot chartId={chartId} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{visibleRows.map((row) => {
|
||||
const counts = rows?.[row.id] ?? EMPTY_COUNTS
|
||||
return (
|
||||
<article key={row.id} className={styles.row}>
|
||||
<div className={styles.rowName}>
|
||||
<h3 className={styles.rowTitle}>{t(row.titleKey)}</h3>
|
||||
{locale === 'fa' ? (
|
||||
<span className={styles.rowTitleEn} lang="en" dir="ltr">
|
||||
{translate('en', row.titleKey)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.chips}>
|
||||
{METRICS.map((metric) => (
|
||||
<div key={metric.id} className={styles.chip}>
|
||||
<strong className={styles.chipValue} lang="en" dir="ltr">
|
||||
{loading || !rows ? '—' : formatCount(counts[metric.id])}
|
||||
</strong>
|
||||
<span className={styles.chipLabel}>{t(metric.labelKey)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user