diff --git a/package.json b/package.json index a7faee1..909bf09 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-label": "^2.1.2", + "@radix-ui/react-popover": "^1.1.23", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-slot": "^1.1.2", "@stripe/react-stripe-js": "^3", @@ -73,6 +74,7 @@ "prism-react-renderer": "^2.3.1", "qs-esm": "8.0.1", "react": "19.2.6", + "react-day-picker": "^10.0.1", "react-dom": "19.2.6", "react-hook-form": "7.71.1", "sharp": "0.35.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3adfbc1..89ce7c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.2 version: 2.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-popover': + specifier: ^1.1.23 + version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-select': specifier: ^2.1.6 version: 2.3.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -126,6 +129,9 @@ importers: react: specifier: 19.2.6 version: 19.2.6 + react-day-picker: + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.2.14)(react@19.2.6) react-dom: specifier: 19.2.6 version: 19.2.6(react@19.2.6) @@ -463,6 +469,9 @@ packages: '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1808,6 +1817,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.23': + resolution: {integrity: sha512-mw58MrBlyHWFisTOYignD0vf/3gdcgAR+9of1s9G/38CbFiUwH1nCDkc0AUM9IrXFgN5Ue8n45j9WCgyM1sbiQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.3.7': resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} peerDependencies: @@ -4718,6 +4740,16 @@ packages: react: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react-dom@19.2.6: resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: @@ -5916,6 +5948,8 @@ snapshots: '@date-fns/tz@1.2.0': {} + '@date-fns/tz@1.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: react: 19.2.6 @@ -7270,6 +7304,29 @@ snapshots: '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-popover@1.1.23(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.14)(react@19.2.6) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.14)(react@19.2.6) + aria-hidden: 1.2.6 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react-dom': 2.1.9(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -10316,6 +10373,14 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) + react-day-picker@10.0.1(@types/react@19.2.14)(react@19.2.6): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.4.0 + react: 19.2.6 + optionalDependencies: + '@types/react': 19.2.14 + react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 diff --git a/src/components/admin/AdminNavigationProgress/index.scss b/src/components/admin/AdminNavigationProgress/index.scss new file mode 100644 index 0000000..a1d6f43 --- /dev/null +++ b/src/components/admin/AdminNavigationProgress/index.scss @@ -0,0 +1,34 @@ +.admin-navigation-progress { + position: fixed; + z-index: 1100; + top: 0; + right: 0; + left: 0; + height: 2px; + overflow: hidden; + background: var(--theme-elevation-100); + pointer-events: none; + + span { + display: block; + width: 38%; + height: 100%; + background: var(--theme-elevation-900); + transform: translateX(-110%); + animation: admin-request-progress 900ms cubic-bezier(0.45, 0, 0.2, 1) infinite; + } +} + +@keyframes admin-request-progress { + 0% { transform: translateX(-110%); } + 55% { transform: translateX(115%); } + 100% { transform: translateX(265%); } +} + +@media (prefers-reduced-motion: reduce) { + .admin-navigation-progress span { + width: 100%; + transform: none; + animation: none; + } +} diff --git a/src/components/admin/AdminNavigationProgress/index.tsx b/src/components/admin/AdminNavigationProgress/index.tsx new file mode 100644 index 0000000..c04a54c --- /dev/null +++ b/src/components/admin/AdminNavigationProgress/index.tsx @@ -0,0 +1,90 @@ +'use client' + +import { usePathname, useSearchParams } from 'next/navigation' +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' + +import './index.scss' + +type ProgressContext = { + start: () => void +} + +const AdminNavigationProgressContext = createContext({ start: () => undefined }) + +export const useAdminNavigationProgress = () => useContext(AdminNavigationProgressContext) + +export const AdminNavigationProgress: React.FC<{ children?: React.ReactNode }> = ({ children }) => { + const pathname = usePathname() + const searchParams = useSearchParams() + const [loadingFrom, setLoadingFrom] = useState(null) + const safetyTimer = useRef | null>(null) + const navigationKey = `${pathname}?${searchParams.toString()}` + const isLoading = loadingFrom === navigationKey + + const start = useCallback(() => { + if (safetyTimer.current) clearTimeout(safetyTimer.current) + setLoadingFrom(navigationKey) + safetyTimer.current = setTimeout(() => setLoadingFrom(null), 15_000) + }, [navigationKey]) + const context = useMemo(() => ({ start }), [start]) + + useEffect(() => () => { + if (safetyTimer.current) clearTimeout(safetyTimer.current) + }, []) + + useEffect(() => { + const handleClick = (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) { + return + } + + const target = event.target + if (!(target instanceof Element)) return + + const anchor = target.closest('a[href]') + if (!anchor || anchor.download || (anchor.target && anchor.target !== '_self')) return + + const destination = new URL(anchor.href, window.location.href) + if (destination.origin !== window.location.origin || !destination.pathname.startsWith('/admin')) { + return + } + + const current = `${window.location.pathname}${window.location.search}` + const next = `${destination.pathname}${destination.search}` + if (next !== current) start() + } + + document.addEventListener('click', handleClick, true) + return () => document.removeEventListener('click', handleClick, true) + }, [start]) + + return ( + + {isLoading && ( +
+ +
+ )} + {children} +
+ ) +} diff --git a/src/components/admin/AdminStyles/index.tsx b/src/components/admin/AdminStyles/index.tsx index 4ac7d70..0a69560 100644 --- a/src/components/admin/AdminStyles/index.tsx +++ b/src/components/admin/AdminStyles/index.tsx @@ -1,5 +1,9 @@ +'use client' + import React from 'react' +import { AdminNavigationProgress } from '../AdminNavigationProgress' + import './nav-icons.scss' import './fields.scss' import './media-grid.scss' @@ -12,5 +16,5 @@ import './media-grid.scss' * sheet loads on every admin screen, including login. */ export const AdminStyles: React.FC<{ children?: React.ReactNode }> = ({ children }) => { - return <>{children} + return {children} } diff --git a/src/components/admin/AnalyticsControls/index.scss b/src/components/admin/AnalyticsControls/index.scss index f1af536..fbdfa64 100644 --- a/src/components/admin/AnalyticsControls/index.scss +++ b/src/components/admin/AnalyticsControls/index.scss @@ -1,98 +1,442 @@ .analytics-controls { display: flex; - flex-wrap: wrap; align-items: center; - gap: 0.85rem 1.25rem; + justify-content: flex-end; + gap: 0.45rem; - &__ranges, - &__comparison { - display: flex; - flex-wrap: wrap; + &__trigger, + &__comparison-trigger { + display: inline-flex; align-items: center; - gap: 0.2rem; - } - - &__range, - &__comparison a { - padding: 0.48rem 0.68rem; - border-radius: var(--style-radius-s); - color: var(--theme-elevation-600); - text-decoration: none; + justify-content: space-between; + gap: 0.45rem; + width: auto; + min-height: 2.15rem; + margin: 0; + padding: 0.4rem 0.65rem; + border: 1px solid var(--theme-elevation-150); + border-radius: var(--style-radius-s, 3px); + color: var(--theme-elevation-800); + background: var(--theme-elevation-0); + box-shadow: none; + font: inherit; + font-size: 0.95rem; + font-weight: 500; + line-height: 1; + cursor: pointer; transition: background-color 120ms ease, - color 120ms ease; + border-color 120ms ease; &:hover { - color: var(--theme-elevation-900); - background: var(--theme-elevation-100); + border-color: var(--theme-elevation-250); + background: var(--theme-elevation-50); } - &[aria-current] { - color: var(--theme-elevation-950); - background: var(--theme-elevation-150); - font-weight: 600; + &:focus-visible { + outline: 2px solid var(--theme-elevation-700); + outline-offset: 2px; + } + + svg { + width: 0.9rem; + height: 0.9rem; + color: var(--theme-elevation-500); } } - &__comparison { - margin-left: auto; + &__trigger { + max-width: min(19rem, 48vw); > span { - margin-right: 0.3rem; - color: var(--theme-elevation-450); - font-size: 0.82rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__caret { + flex: 0 0 auto; + margin-left: 0.15rem; + } + + &__comparison-label { + color: var(--theme-elevation-450); + font-weight: 400; + } + + @media (max-width: 40rem) { + align-items: stretch; + + &__trigger, + &__comparison-trigger { + flex: 1 1 0; + min-width: 0; + } + + &__comparison-label { + display: none; } } +} + +.analytics-controls__popover { + z-index: 1000; + display: grid; + box-sizing: border-box; + grid-template-columns: 9.5rem minmax(17.5rem, 1fr); + width: min(31rem, calc(100vw - 1.5rem)); + overflow: hidden; + border: 1px solid var(--theme-elevation-150); + border-radius: var(--style-radius-s, 3px); + color: var(--theme-elevation-800); + background: var(--theme-elevation-0); + box-shadow: 0 5px 16px rgb(0 0 0 / 0.12); + + &[data-state='open'] { + animation: analytics-popover-in 120ms ease-out; + } +} + +.analytics-controls__presets { + display: flex; + flex-direction: column; + gap: 0.15rem; + padding: 0.7rem; + background: var(--theme-elevation-50); + + > p { + margin: 0 0 0.35rem; + padding: 0 0.45rem; + color: var(--theme-elevation-450); + font-size: 0.85rem; + } +} + +.analytics-controls__preset { + display: flex; + justify-content: flex-start; + width: 100%; + min-height: 2rem; + padding: 0.42rem 0.45rem; + border: 0; + border-radius: var(--style-radius-s, 3px); + color: var(--theme-elevation-700); + background: transparent; + font: inherit; + font-size: 0.925rem; + text-align: left; + cursor: pointer; + + &:hover, + &[aria-current='true'] { + color: var(--theme-elevation-950); + background: var(--theme-elevation-150); + } - &__custom { - flex: 1 0 100%; + &:focus-visible { + outline: 2px solid var(--theme-elevation-700); + outline-offset: -1px; + } +} + +.analytics-controls__custom { + min-width: 0; + padding: 0.8rem; + + &-head { display: flex; - flex-wrap: wrap; - align-items: end; + align-items: flex-start; + justify-content: space-between; gap: 0.75rem; - padding-top: 0.75rem; + min-height: 2.6rem; + margin-bottom: 0.55rem; - label { - display: grid; - gap: 0.35rem; - color: var(--theme-elevation-550); - font-size: 0.78rem; + > div { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; } - input { - min-height: 2.4rem; - padding: 0.45rem 0.65rem; - border: 1px solid var(--theme-elevation-200); - border-radius: var(--style-radius-s); + strong { color: var(--theme-elevation-900); - background: var(--theme-input-bg); - font: inherit; + font-size: 1rem; + font-weight: 600; + } + + span { + overflow: hidden; + color: var(--theme-elevation-450); + font-size: 0.85rem; + text-overflow: ellipsis; + white-space: nowrap; } button { - min-height: 2.4rem; - padding: 0.45rem 0.9rem; + min-height: 1.9rem; + padding: 0.35rem 0.65rem; border: 0; - border-radius: var(--style-radius-s); + border-radius: var(--style-radius-s, 3px); color: var(--theme-elevation-0); background: var(--theme-elevation-900); + box-shadow: none; font: inherit; + font-size: 0.9rem; font-weight: 600; cursor: pointer; + + &:hover { + background: var(--theme-elevation-750); + } + + &:focus-visible { + outline: 2px solid var(--theme-elevation-700); + outline-offset: 2px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } + } + } +} + +.analytics-controls__error { + margin: 0.45rem 0 0; + color: var(--theme-error-500); + font-size: 0.9rem; +} + +.analytics-controls__select-content { + z-index: 1000; + min-width: 12rem; + padding: 0.25rem; + overflow: hidden; + border: 1px solid var(--theme-elevation-150); + border-radius: var(--style-radius-s, 3px); + color: var(--theme-elevation-800); + background: var(--theme-elevation-0); + box-shadow: 0 5px 16px rgb(0 0 0 / 0.12); +} + +.analytics-controls__select-item { + position: relative; + display: flex; + align-items: center; + min-height: 2rem; + padding: 0.42rem 1.8rem 0.42rem 0.55rem; + border-radius: var(--style-radius-s, 3px); + color: var(--theme-elevation-750); + font-size: 0.925rem; + outline: none; + cursor: default; + + &[data-highlighted] { + color: var(--theme-elevation-950); + background: var(--theme-elevation-100); + } + + > span:first-child { + position: absolute; + right: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + } +} + +.calendar { + color: var(--theme-elevation-800); + + &__month { + position: relative; + } + + &__nav { + position: absolute; + z-index: 1; + top: 0; + right: 0; + left: 0; + display: flex; + justify-content: space-between; + pointer-events: none; + } + + &__nav-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: 0; + border-radius: var(--style-radius-s, 3px); + color: var(--theme-elevation-550); + background: transparent; + box-shadow: none; + pointer-events: auto; + cursor: pointer; + + &:hover { + color: var(--theme-elevation-900); + background: var(--theme-elevation-100); } - p { - flex: 1 0 100%; - margin: 0; - color: var(--theme-error-500); - font-size: 0.8rem; + &:focus-visible { + outline: 2px solid var(--theme-elevation-700); + outline-offset: -1px; } + + svg { + width: 0.9rem; + height: 0.9rem; + } + } + + &__caption { + display: flex; + align-items: center; + justify-content: center; + height: 2rem; + margin-bottom: 0.35rem; + } + + &__caption-label { + color: var(--theme-elevation-850); + font-size: 0.925rem; + font-weight: 600; + } + + &__grid { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + } + + &__weekday { + height: 1.65rem; + color: var(--theme-elevation-400); + font-size: 0.8rem; + font-weight: 500; + text-align: center; + } + + &__day { + position: relative; + height: 2rem; + padding: 1px; + text-align: center; } - @media (max-width: 58rem) { - &__comparison { - flex-basis: 100%; - margin-left: 0; + &__day-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 1.9rem; + min-width: 0; + padding: 0; + border: 0; + border-radius: var(--style-radius-s, 3px); + color: var(--theme-elevation-750); + background: transparent; + box-shadow: none; + font: inherit; + font-size: 0.875rem; + font-variant-numeric: tabular-nums; + cursor: pointer; + + &:hover { + color: var(--theme-elevation-950); + background: var(--theme-elevation-100); + } + + &:focus-visible { + position: relative; + z-index: 2; + outline: 2px solid var(--theme-elevation-700); + outline-offset: -1px; + } + + &[data-range-start='true'], + &[data-range-end='true'], + &[data-selected-single='true'] { + color: var(--theme-elevation-0); + background: var(--theme-elevation-900); } + + &[data-range-middle='true'] { + border-radius: 0; + color: var(--theme-elevation-900); + background: var(--theme-elevation-100); + } + } + + &__range-start, + &__range-middle, + &__range-end { + background: var(--theme-elevation-100); + } + + &__range-start { + border-radius: var(--style-radius-s, 3px) 0 0 var(--style-radius-s, 3px); + } + + &__range-end { + border-radius: 0 var(--style-radius-s, 3px) var(--style-radius-s, 3px) 0; + } + + &__today .calendar__day-button { + font-weight: 700; + box-shadow: inset 0 -2px 0 var(--theme-elevation-400); + } + + &__outside { + opacity: 0.35; + } + + &__disabled { + opacity: 0.28; + + .calendar__day-button { + cursor: not-allowed; + } + } + + &__hidden { + visibility: hidden; + } +} + +@keyframes analytics-popover-in { + from { + transform: translateY(-3px); + } + to { + transform: translateY(0); + } +} + +@media (max-width: 34rem) { + .analytics-controls__popover { + grid-template-columns: minmax(0, 1fr); + } + + .analytics-controls__presets { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + + > p { + grid-column: 1 / -1; + } + } +} + +@media (prefers-reduced-motion: reduce) { + .analytics-controls__popover[data-state='open'] { + animation: none; } } diff --git a/src/components/admin/AnalyticsControls/index.tsx b/src/components/admin/AnalyticsControls/index.tsx index cb9b26f..a1d5e68 100644 --- a/src/components/admin/AnalyticsControls/index.tsx +++ b/src/components/admin/AnalyticsControls/index.tsx @@ -1,11 +1,25 @@ 'use client' -import Link from 'next/link' +import { CalendarBlank, CaretDown } from '@phosphor-icons/react' +import { format, parseISO } from 'date-fns' import { useRouter } from 'next/navigation' -import React from 'react' -import { useForm } from 'react-hook-form' +import React, { useEffect, useState, useTransition } from 'react' +import { type DateRange } from 'react-day-picker' +import { useForm, useWatch } from 'react-hook-form' + +import { Button } from '@/components/ui/button' +import { Calendar } from '@/components/ui/calendar' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' import type { AnalyticsComparison, AnalyticsRange } from '../Dashboard/analyticsRange' +import { useAdminNavigationProgress } from '../AdminNavigationProgress' import './index.scss' @@ -23,13 +37,17 @@ type CustomRangeFields = { from: string; to: string } const calendarDays = (from: string, to: string) => Math.floor((Date.parse(`${to}T12:00:00Z`) - Date.parse(`${from}T12:00:00Z`)) / 86_400_000) + 1 -const options: Array<{ label: string; value: AnalyticsRange }> = [ +const options: Array<{ label: string; value: Exclude }> = [ { label: 'Today', value: 'today' }, - { label: '7 days', value: '7d' }, - { label: '30 days', value: '30d' }, - { label: '90 days', value: '90d' }, + { label: 'Last 7 days', value: '7d' }, + { label: 'Last 30 days', value: '30d' }, + { label: 'Last 90 days', value: '90d' }, { label: 'Year to date', value: 'ytd' }, - { label: 'Custom', value: 'custom' }, +] + +const comparisonOptions: Array<{ label: string; value: AnalyticsComparison }> = [ + { label: 'Previous period', value: 'previous' }, + { label: 'Previous year', value: 'year' }, ] const hrefFor = ( @@ -47,6 +65,18 @@ const hrefFor = ( return `${adminPath}?${query.toString()}` } +const humanDate = new Intl.DateTimeFormat('en', { + day: 'numeric', + month: 'short', + year: 'numeric', +}) + +const rangeLabel = (range: AnalyticsRange, from: string, to: string) => { + if (range !== 'custom') + return options.find((option) => option.value === range)?.label ?? 'Date range' + return `${humanDate.format(parseISO(from))} - ${humanDate.format(parseISO(to))}` +} + export const AnalyticsControls: React.FC = ({ adminPath, comparison, @@ -56,65 +86,111 @@ export const AnalyticsControls: React.FC = ({ startDate, }) => { const router = useRouter() + const { start: startNavigation } = useAdminNavigationProgress() + const [isPending, startTransition] = useTransition() + const [rangeOpen, setRangeOpen] = useState(false) const { + control, formState: { errors }, handleSubmit, register, + reset, + setValue, } = useForm({ defaultValues: { from: startDate, to: endDate } }) + const [from, to] = useWatch({ control, name: ['from', 'to'] }) - const applyCustom = handleSubmit(({ from, to }) => { - router.push(hrefFor(adminPath, 'custom', comparison, from, to)) + useEffect(() => { + reset({ from: startDate, to: endDate }) + }, [endDate, reset, startDate]) + + const navigate = (href: string) => { + startNavigation() + startTransition(() => router.push(href)) + } + + const applyCustom = handleSubmit(({ from: customFrom, to: customTo }) => { + setRangeOpen(false) + navigate(hrefFor(adminPath, 'custom', comparison, customFrom, customTo)) }) + const choosePreset = (nextRange: Exclude) => { + setRangeOpen(false) + navigate(hrefFor(adminPath, nextRange, comparison)) + } + + const selectCustomRange = (selected: DateRange | undefined) => { + if (!selected?.from) return + const nextFrom = format(selected.from, 'yyyy-MM-dd') + const nextTo = selected.to ? format(selected.to, 'yyyy-MM-dd') : '' + setValue('from', nextFrom, { shouldDirty: true, shouldValidate: true }) + setValue('to', nextTo, { shouldDirty: true, shouldValidate: true }) + } + return ( -
- - -
- Compare - - Previous period - - - Previous year - -
- - {range === 'custom' && ( -
- - - - {(errors.from?.message || errors.to?.message) && ( -

{errors.from?.message || errors.to?.message}

- )} -
- )} + + + + {(errors.from?.message || errors.to?.message) && ( +

+ {errors.from?.message || errors.to?.message} +

+ )} + + + + +
) } diff --git a/src/components/admin/AttentionQueue/index.scss b/src/components/admin/AttentionQueue/index.scss index 321dc72..208b04d 100644 --- a/src/components/admin/AttentionQueue/index.scss +++ b/src/components/admin/AttentionQueue/index.scss @@ -16,13 +16,13 @@ align-items: center; gap: 0.5rem; margin: 0; - font-size: 0.95rem; + font-size: 1.08rem; font-weight: 600; } > span { color: var(--theme-elevation-450); - font-size: 0.78rem; + font-size: 0.925rem; font-variant-numeric: tabular-nums; } } @@ -34,17 +34,17 @@ &__items { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.45rem; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.35rem; } &__item { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; - gap: 0.8rem; + gap: 0.6rem; min-width: 0; - padding: 0.75rem 0.85rem; + padding: 0.52rem 0.7rem; border-radius: var(--style-radius-s, 3px); color: var(--theme-elevation-850); background: var(--theme-elevation-50); @@ -68,19 +68,19 @@ &__copy { display: flex; flex-direction: column; - gap: 0.16rem; + gap: 0.08rem; min-width: 0; strong { color: var(--theme-elevation-900); - font-size: 0.86rem; + font-size: 1rem; font-weight: 600; } > span { color: var(--theme-elevation-500); - font-size: 0.76rem; - line-height: 1.35; + font-size: 0.925rem; + line-height: 1.25; } } @@ -93,7 +93,7 @@ &__arrow { color: var(--theme-elevation-450); - font-size: 1rem; + font-size: 0.85rem; } &__empty { diff --git a/src/components/admin/Dashboard/index.scss b/src/components/admin/Dashboard/index.scss index 269609c..c8668cf 100644 --- a/src/components/admin/Dashboard/index.scss +++ b/src/components/admin/Dashboard/index.scss @@ -22,14 +22,14 @@ &__hello { margin: 0; - font-size: 1.55rem; + font-size: 1.85rem; font-weight: 600; letter-spacing: -0.01em; } &__date { margin: 0; - font-size: 0.875rem; + font-size: 1rem; color: var(--theme-elevation-450); } @@ -69,7 +69,7 @@ } &__stat-label { - font-size: 0.8rem; + font-size: 1rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--theme-elevation-500); @@ -83,7 +83,7 @@ } &__stat-hint { - font-size: 0.8rem; + font-size: 0.925rem; color: var(--theme-elevation-450); } @@ -124,7 +124,7 @@ &__panel-heading { margin: 0 0 0.85rem; - font-size: 0.95rem; + font-size: 1.08rem; font-weight: 600; display: flex; align-items: center; @@ -134,7 +134,7 @@ &__panel-icon { color: var(--theme-elevation-500); flex: 0 0 auto; } &__panel-note { - font-size: 0.78rem; + font-size: 0.925rem; font-weight: 400; color: var(--theme-elevation-450); } diff --git a/src/components/admin/KpiStrip/index.scss b/src/components/admin/KpiStrip/index.scss index e3c1701..915d4b5 100644 --- a/src/components/admin/KpiStrip/index.scss +++ b/src/components/admin/KpiStrip/index.scss @@ -18,7 +18,7 @@ } &__label { - font-size: 0.85rem; + font-size: 1rem; color: var(--theme-elevation-500); display: flex; align-items: center; @@ -38,7 +38,7 @@ } &__value { - font-size: 1.7rem; + font-size: 2rem; font-weight: 600; line-height: 1.05; font-variant-numeric: tabular-nums; @@ -46,7 +46,7 @@ } &__delta { - font-size: 0.85rem; + font-size: 0.95rem; font-weight: 600; font-variant-numeric: tabular-nums; display: inline-flex; @@ -59,7 +59,7 @@ } &__compare { - font-size: 0.8rem; + font-size: 0.925rem; color: var(--theme-elevation-450); margin-top: -0.15rem; } diff --git a/src/components/admin/OrderHealth/index.scss b/src/components/admin/OrderHealth/index.scss index afdfddf..2365e78 100644 --- a/src/components/admin/OrderHealth/index.scss +++ b/src/components/admin/OrderHealth/index.scss @@ -22,7 +22,7 @@ &__title { margin: 0 0 0.85rem; - font-size: 0.95rem; + font-size: 1.08rem; font-weight: 600; display: flex; align-items: center; @@ -32,7 +32,7 @@ &__icon { color: var(--theme-elevation-500); flex: 0 0 auto; } &__note { - font-size: 0.78rem; + font-size: 0.925rem; font-weight: 400; color: var(--theme-elevation-450); } @@ -87,12 +87,12 @@ } &__tip-label { - font-size: 0.72rem; + font-size: 0.85rem; color: var(--theme-elevation-500); } &__tip-value { - font-size: 0.85rem; + font-size: 1rem; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--theme-elevation-900); @@ -105,7 +105,7 @@ margin: 0.8rem 0 0; padding: 0; list-style: none; - font-size: 0.85rem; + font-size: 0.95rem; color: var(--theme-elevation-650); li { display: flex; align-items: center; gap: 0.45rem; } diff --git a/src/components/admin/PopularProducts/index.scss b/src/components/admin/PopularProducts/index.scss index 6810754..7e2892b 100644 --- a/src/components/admin/PopularProducts/index.scss +++ b/src/components/admin/PopularProducts/index.scss @@ -23,7 +23,7 @@ &__title { margin: 0 0 1.15rem; - font-size: 0.95rem; + font-size: 1.08rem; font-weight: 600; display: flex; align-items: center; @@ -49,10 +49,10 @@ margin-bottom: 0.4rem; } - &__name { font-size: 0.875rem; min-width: 0; overflow-wrap: anywhere; } + &__name { font-size: 1rem; min-width: 0; overflow-wrap: anywhere; } &__units { - font-size: 0.8rem; + font-size: 0.925rem; color: var(--theme-elevation-500); font-variant-numeric: tabular-nums; white-space: nowrap; @@ -90,7 +90,7 @@ margin-top: 0.9rem; padding-top: 0.6rem; border-top: 1px solid var(--theme-elevation-100); - font-size: 0.72rem; + font-size: 0.85rem; color: var(--theme-elevation-450); font-variant-numeric: tabular-nums; } diff --git a/src/components/admin/RecentOrders/index.scss b/src/components/admin/RecentOrders/index.scss index 511e248..c8a9ea9 100644 --- a/src/components/admin/RecentOrders/index.scss +++ b/src/components/admin/RecentOrders/index.scss @@ -16,13 +16,13 @@ align-items: center; gap: 0.5rem; margin: 0; - font-size: 0.95rem; + font-size: 1.08rem; font-weight: 600; } a { color: var(--theme-elevation-650); - font-size: 0.8rem; + font-size: 0.925rem; text-decoration: none; &:hover { @@ -51,7 +51,7 @@ &__labels { padding: 0 0.7rem 0.45rem; color: var(--theme-elevation-450); - font-size: 0.72rem; + font-size: 0.85rem; } &__row { @@ -59,7 +59,7 @@ padding: 0.68rem 0.7rem; border-radius: var(--style-radius-s, 3px); color: var(--theme-elevation-700); - font-size: 0.82rem; + font-size: 1rem; text-decoration: none; transition: background-color 120ms ease; @@ -100,7 +100,7 @@ color: var(--theme-elevation-650); &--succeeded { - color: var(--theme-success-600, #16803b); + color: #16803b; } &--failed { @@ -111,6 +111,18 @@ &--processing { color: var(--theme-warning-600, #9a6700); } + + &--completed { + color: #16803b; + } + + &--cancelled { + color: #b42318; + } + + &--refunded { + color: var(--theme-elevation-600); + } } &__empty { @@ -164,3 +176,37 @@ } } } + +@media (prefers-color-scheme: dark) { + :root:where(:not([data-theme='light'])) .recent-orders__status { + &--succeeded, + &--completed { + color: #4ade80; + } + + &--pending, + &--processing { + color: #fbbf24; + } + + &--cancelled { + color: #f87171; + } + } +} + +:root[data-theme='dark'] .recent-orders__status { + &--succeeded, + &--completed { + color: #4ade80; + } + + &--pending, + &--processing { + color: #fbbf24; + } + + &--cancelled { + color: #f87171; + } +} diff --git a/src/components/admin/RecentOrders/index.tsx b/src/components/admin/RecentOrders/index.tsx index bf776e2..a888ae4 100644 --- a/src/components/admin/RecentOrders/index.tsx +++ b/src/components/admin/RecentOrders/index.tsx @@ -66,7 +66,9 @@ export const RecentOrders: React.FC = ({ adminPath, orders, timeZone }) = > {words(order.paymentStatus)} - + {fulfillmentLabel(order.fulfillmentStatus)}