diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js index 0a2c8bf..0e245fa 100644 --- a/src/app/(payload)/admin/importMap.js +++ b/src/app/(payload)/admin/importMap.js @@ -21,13 +21,16 @@ import { SlugField as SlugField_2b8867833a34864a02ddf429b0728a40 } from '@payloa import { VariantOptionsSelector as VariantOptionsSelector_b91672ccd6e8b071c11142ab941fedfb } from '@payloadcms/plugin-ecommerce/rsc' import { PriceCell as PriceCell_e27bf7b8cc50640dcdd584767b8eac3c } from '@payloadcms/plugin-ecommerce/client' import { PriceInput as PriceInput_b91672ccd6e8b071c11142ab941fedfb } from '@payloadcms/plugin-ecommerce/rsc' +import { ProductPublishing as ProductPublishing_2e09d0ed302e9b72a91fa96d8b38a01d } from '@/components/admin/ProductPublishing' import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client' +import { ProductInventoryHint as ProductInventoryHint_545506debe207e4feb643fee5c5d7763 } from '@/components/admin/ProductInventoryHint' +import { ProductPublishButton as ProductPublishButton_98537d810be5839e22cd240d7c917c4f } from '@/components/admin/ProductPublishButton' +import { AdminNav as AdminNav_2623bc737c612ce9420322f2210a7f19 } from '@/components/admin/AdminNav' import { BeforeLogin as BeforeLogin_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin' -import { DashboardNavLink as DashboardNavLink_3987d42d9edba53cc710fb1f6cc541b5 } from '@/components/admin/DashboardNavLink' import { AdminStyles as AdminStyles_340cf3538fa47d0416e0abd461790ca4 } from '@/components/admin/AdminStyles' -import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client' import { DashboardView as DashboardView_8d5ccf53266e3797f61e9453e2f2d431 } from '@/components/admin/DashboardView' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' +import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client' /** @type import('payload').ImportMap */ export const importMap = { @@ -54,11 +57,14 @@ export const importMap = { "@payloadcms/plugin-ecommerce/rsc#VariantOptionsSelector": VariantOptionsSelector_b91672ccd6e8b071c11142ab941fedfb, "@payloadcms/plugin-ecommerce/client#PriceCell": PriceCell_e27bf7b8cc50640dcdd584767b8eac3c, "@payloadcms/plugin-ecommerce/rsc#PriceInput": PriceInput_b91672ccd6e8b071c11142ab941fedfb, + "@/components/admin/ProductPublishing#ProductPublishing": ProductPublishing_2e09d0ed302e9b72a91fa96d8b38a01d, "@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864, + "@/components/admin/ProductInventoryHint#ProductInventoryHint": ProductInventoryHint_545506debe207e4feb643fee5c5d7763, + "@/components/admin/ProductPublishButton#ProductPublishButton": ProductPublishButton_98537d810be5839e22cd240d7c917c4f, + "@/components/admin/AdminNav#AdminNav": AdminNav_2623bc737c612ce9420322f2210a7f19, "@/components/BeforeLogin#BeforeLogin": BeforeLogin_8a7ab0eb7ab5c511aba12e68480bfe5e, - "@/components/admin/DashboardNavLink#DashboardNavLink": DashboardNavLink_3987d42d9edba53cc710fb1f6cc541b5, "@/components/admin/AdminStyles#AdminStyles": AdminStyles_340cf3538fa47d0416e0abd461790ca4, - "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24, "@/components/admin/DashboardView#DashboardView": DashboardView_8d5ccf53266e3797f61e9453e2f2d431, - "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 + "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1, + "@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } diff --git a/src/collections/Products/index.ts b/src/collections/Products/index.ts index f5150f1..5575435 100644 --- a/src/collections/Products/index.ts +++ b/src/collections/Products/index.ts @@ -1,7 +1,7 @@ import { CallToAction } from '@/blocks/CallToAction/config' import { Content } from '@/blocks/Content/config' import { MediaBlock } from '@/blocks/MediaBlock/config' -import { slugField } from 'payload' +import { type Field, slugField } from 'payload' import { generatePreviewPath } from '@/utilities/generatePreviewPath' import { CollectionOverride } from '@payloadcms/plugin-ecommerce/types' import { @@ -22,10 +22,29 @@ import { DefaultDocumentIDType, Where } from 'payload' import { priceSelect } from '@/currencies' -export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => ({ +const named = (field: Field, name: string) => 'name' in field && field.name === name + +export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => { + const defaults = defaultCollection.fields + const inventory = defaults.find((field) => named(field, 'inventory')) + const variantFields = defaults.filter((field) => + ['enableVariants', 'variantTypes', 'variants'].some((name) => named(field, name)), + ) + const priceFields = defaults.filter((field) => field.type === 'group') + const slug = slugField() + const slugFields = slug.type === 'row' ? slug.fields : [] + + return ({ ...defaultCollection, admin: { ...defaultCollection?.admin, + components: { + ...defaultCollection?.admin?.components, + edit: { + ...defaultCollection?.admin?.components?.edit, + PublishButton: '@/components/admin/ProductPublishButton#ProductPublishButton', + }, + }, defaultColumns: ['title', 'enableVariants', '_status', 'variants.variants'], livePreview: { url: ({ data, req }) => @@ -56,12 +75,31 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => meta: true, }, fields: [ - { name: 'title', type: 'text', required: true }, { - type: 'tabs', - tabs: [ + name: 'publishingSummary', + type: 'ui', + admin: { + components: { Field: '@/components/admin/ProductPublishing#ProductPublishing' }, + position: 'sidebar', + }, + }, + { + type: 'collapsible', + label: 'Basic information', + admin: { className: 'product-editor__section product-editor__basic', initCollapsed: false }, + fields: [ { + type: 'row', fields: [ + { name: 'title', type: 'text', required: true, admin: { width: '50%' } }, + ...slugFields.map((field): Field => { + if ('name' in field && field.name === 'slug') { + return { ...field, admin: { ...field.admin, width: '50%' } } as Field + } + return field + }), + ], + }, { name: 'description', type: 'richText', @@ -79,6 +117,13 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => label: false, required: false, }, + ], + }, + { + type: 'collapsible', + label: 'Media', + admin: { className: 'product-editor__section product-editor__media', initCollapsed: false }, + fields: [ { name: 'gallery', type: 'array', @@ -133,18 +178,24 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => }, ], }, - + ], + }, + { + type: 'collapsible', + label: 'Variants', + admin: { className: 'product-editor__section product-editor__variants', initCollapsed: false }, + fields: variantFields, + }, + { + type: 'collapsible', + label: 'Additional content', + admin: { className: 'product-editor__section product-editor__additional', initCollapsed: true }, + fields: [ { name: 'layout', type: 'blocks', blocks: [CallToAction, Content, MediaBlock], }, - ], - label: 'Content', - }, - { - fields: [ - ...defaultCollection.fields, { name: 'relatedProducts', type: 'relationship', @@ -167,12 +218,17 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => hasMany: true, relationTo: 'products', }, - ], - label: 'Product Details', - }, + ], + }, + { + type: 'collapsible', + label: 'Search preview', + admin: { className: 'product-editor__section product-editor__seo', initCollapsed: true }, + fields: [ { name: 'meta', label: 'SEO', + type: 'group', fields: [ OverviewField({ titlePath: 'meta.title', @@ -200,25 +256,57 @@ export const ProductsCollection: CollectionOverride = ({ defaultCollection }) => ], }, { - name: 'categories', - type: 'relationship', + type: 'collapsible', + label: 'Product organization', admin: { + className: 'product-editor__sidebar-card product-editor__organization', + initCollapsed: false, position: 'sidebar', - sortOptions: 'title', }, - hasMany: true, - relationTo: 'categories', + fields: [ + { + name: 'categories', + type: 'relationship', + admin: { sortOptions: 'title' }, + hasMany: true, + relationTo: 'categories', + }, + { + name: 'tags', + type: 'relationship', + admin: { sortOptions: 'group' }, + hasMany: true, + relationTo: 'tags', + }, + ], }, + ...(inventory ? [{ + type: 'collapsible' as const, + label: 'Inventory', + admin: { + className: 'product-editor__sidebar-card product-editor__inventory', + initCollapsed: false, + position: 'sidebar' as const, + }, + fields: [ + { + name: 'inventoryHint', + type: 'ui', + admin: { components: { Field: '@/components/admin/ProductInventoryHint#ProductInventoryHint' } }, + }, + inventory, + ], + }] : []), { - name: 'tags', - type: 'relationship', + type: 'collapsible', + label: 'Pricing', admin: { + className: 'product-editor__sidebar-card product-editor__pricing', + initCollapsed: false, position: 'sidebar', - sortOptions: 'group', }, - hasMany: true, - relationTo: 'tags', + fields: priceFields, }, - slugField(), - ], -}) + ] as Field[], + }) +} diff --git a/src/components/admin/AdminNav/index.scss b/src/components/admin/AdminNav/index.scss new file mode 100644 index 0000000..b585203 --- /dev/null +++ b/src/components/admin/AdminNav/index.scss @@ -0,0 +1,226 @@ +.custom-admin-nav { + display: flex !important; + overflow: hidden; + flex-direction: column; + padding: 1.15rem 0.75rem 0.8rem; +} + +.custom-admin-nav::before, +.custom-admin-nav::after { + content: none !important; +} + +.custom-admin-nav__brand { + display: grid; + gap: 0.18rem; + padding: 0 0.65rem 1rem; + + span { + color: #fff; + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.28rem; + line-height: 1.1; + letter-spacing: 0.01em; + } + + small { + color: var(--admin-sidebar-muted); + font-size: 0.7rem; + } +} + +.custom-admin-nav__search { + display: grid; + min-height: 2.55rem; + grid-template-columns: 1.1rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.75rem; + padding: 0 0.7rem; + border-radius: var(--admin-radius-control); + color: var(--admin-sidebar-text); + background: rgb(255 255 255 / 0.065); + transition: background-color 140ms ease; + + &:focus-within { background: rgb(255 255 255 / 0.1); } + + svg { + width: 1.05rem; + height: 1.05rem; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; + } + + input { + min-width: 0; + height: auto !important; + padding: 0 !important; + border: 0 !important; + border-radius: 0 !important; + outline: 0; + color: var(--admin-sidebar-text) !important; + background: transparent !important; + box-shadow: none !important; + font: inherit; + font-size: 0.82rem; + + &::placeholder { color: var(--admin-sidebar-text); opacity: 0.9; } + } + + kbd { + padding: 0.16rem 0.34rem; + border: 0; + border-radius: 5px; + color: var(--admin-sidebar-muted); + background: rgb(255 255 255 / 0.08); + font: inherit; + font-size: 0.64rem; + } +} + +.custom-admin-nav__links { + display: grid; + gap: 0.2rem; +} + +.custom-admin-nav__link { + display: grid; + min-height: 2.3rem; + grid-template-columns: 1.2rem minmax(0, 1fr) auto; + align-items: center; + gap: 0.7rem; + padding: 0.48rem 0.65rem; + border-radius: var(--admin-radius-control); + color: var(--admin-sidebar-text); + font-size: 0.83rem; + font-weight: 450; + line-height: 1.2; + text-decoration: none; + transition: color 140ms ease, background-color 140ms ease; + + > svg { + width: 1.1rem; + height: 1.1rem; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.55; + } + + &:hover { + color: #fff; + background: var(--admin-sidebar-hover); + } + + &:focus-visible { + outline: 2px solid rgb(255 255 255 / 0.82); + outline-offset: 2px; + } +} + +.custom-admin-nav__link--active { + color: #fff; + background: var(--admin-sidebar-active); + font-weight: 600; +} + +.custom-admin-nav__count { + display: grid; + min-width: 1.55rem; + height: 1.45rem; + place-items: center; + padding: 0 0.35rem; + border-radius: 0.42rem; + color: #fff; + background: rgb(255 255 255 / 0.1); + font-size: 0.7rem; + font-variant-numeric: tabular-nums; + font-weight: 600; +} + +.custom-admin-nav__footer { + display: grid; + gap: 0.55rem; + margin-top: auto; +} + +.custom-admin-nav__account { + display: grid; + min-width: 0; + grid-template-columns: 2.15rem minmax(0, 1fr) 1rem; + align-items: center; + gap: 0.65rem; + padding: 0.7rem 0.65rem 0.15rem; + border-top: 1px solid rgb(255 255 255 / 0.08); + color: var(--admin-sidebar-text); + text-decoration: none; + + > svg { + width: 0.95rem; + height: 0.95rem; + stroke: var(--admin-sidebar-muted); + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; + transition: transform 140ms ease, stroke 140ms ease; + } + + &:hover > svg { stroke: #fff; transform: translateX(2px); } + &:focus-visible { outline: 2px solid rgb(255 255 255 / 0.82); outline-offset: 2px; } +} + +.custom-admin-nav__avatar { + display: grid; + width: 2.15rem; + height: 2.15rem; + place-items: center; + border-radius: 50%; + color: #1c1d1a; + background: #dedfd7; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.custom-admin-nav__identity { + display: grid; + min-width: 0; + gap: 0.1rem; + + strong, + small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { color: #fff; font-size: 0.78rem; font-weight: 600; } + small { color: var(--admin-sidebar-muted); font-size: 0.67rem; } +} + +@media (max-width: 1024px) { + .template-default .custom-admin-nav { + position: fixed !important; + z-index: 3; + inset: 0 auto 0 0; + width: min(288px, calc(100vw - 3.5rem)) !important; + height: 100dvh !important; + border-radius: 0 var(--admin-radius-shell) var(--admin-radius-shell) 0 !important; + opacity: 1 !important; + visibility: visible !important; + transform: translateX(-105%) !important; + transition: transform 180ms ease !important; + } + + .template-default .custom-admin-nav--open { transform: translateX(0) !important; } +} + +@media (prefers-reduced-motion: reduce) { + .custom-admin-nav, + .custom-admin-nav__account > svg, + .custom-admin-nav__link { + transition: none !important; + } +} diff --git a/src/components/admin/AdminNav/index.tsx b/src/components/admin/AdminNav/index.tsx new file mode 100644 index 0000000..461e01c --- /dev/null +++ b/src/components/admin/AdminNav/index.tsx @@ -0,0 +1,266 @@ +'use client' + +import { useAuth, useNav } from '@payloadcms/ui' +import Link from 'next/link' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import React, { useEffect, useRef, useState } from 'react' + +import type { User } from '@/payload-types' + +import './index.scss' + +type IconProps = { className?: string } + +const Icon = ({ children, className }: React.PropsWithChildren) => ( + +) + +const HomeIcon = (props: IconProps) => ( + + + + +) + +const OrdersIcon = (props: IconProps) => ( + + + + +) + +const ProductsIcon = (props: IconProps) => ( + + + + +) + +const CustomersIcon = (props: IconProps) => ( + + + + +) + +const InventoryIcon = (props: IconProps) => ( + + + + +) + +const MarketingIcon = (props: IconProps) => ( + + + + +) + +const AnalyticsIcon = (props: IconProps) => ( + + + +) + +const ContentIcon = (props: IconProps) => ( + + + + + +) + +const SettingsIcon = (props: IconProps) => ( + + + + +) + +const SearchIcon = (props: IconProps) => ( + + + + +) + +const ChevronIcon = (props: IconProps) => ( + + + +) + +const mainLinks = [ + { Icon: HomeIcon, href: '/admin', label: 'Dashboard', match: 'dashboard' }, + { Icon: OrdersIcon, href: '/admin/collections/orders', label: 'Orders', match: 'orders' }, + { Icon: ProductsIcon, href: '/admin/collections/products', label: 'Products', match: 'products' }, + { Icon: CustomersIcon, href: '/admin/collections/users', label: 'Customers', match: 'customers' }, + { + Icon: InventoryIcon, + href: '/admin/collections/products?limit=10&where[inventory][less_than_equal]=25', + label: 'Inventory', + match: 'inventory', + }, + { Icon: MarketingIcon, href: '/admin/collections/forms', label: 'Marketing', match: 'marketing' }, + { + Icon: AnalyticsIcon, + href: '/admin?range=30d&compare=previous§ion=analytics#analytics', + label: 'Analytics', + match: 'analytics', + }, + { Icon: ContentIcon, href: '/admin/collections/pages', label: 'Content', match: 'content' }, +] as const + +const initialsFor = (name?: null | string, email?: null | string) => { + const source = name?.trim() || email?.split('@')[0] || 'Admin' + const parts = source.split(/\s+/).filter(Boolean) + return (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)?.[0]}` : source.slice(0, 2)).toUpperCase() +} + +export const AdminNav: React.FC = () => { + const pathname = usePathname() + const router = useRouter() + const searchParams = useSearchParams() + const { user } = useAuth() + const { navOpen, navRef, setNavOpen } = useNav() + const [orderCount, setOrderCount] = useState(null) + const searchRef = useRef(null) + + const section = searchParams.get('section') + const hasInventoryFilter = searchParams.toString().includes('inventory') + + useEffect(() => { + const controller = new AbortController() + + void fetch('/api/orders?limit=1&depth=0&where[status][equals]=processing', { + signal: controller.signal, + }) + .then((response) => (response.ok ? response.json() : null)) + .then((data) => { + if (typeof data?.totalDocs === 'number') setOrderCount(data.totalDocs) + }) + .catch(() => undefined) + + return () => controller.abort() + }, []) + + useEffect(() => { + const handleShortcut = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + event.preventDefault() + searchRef.current?.focus() + } + } + + window.addEventListener('keydown', handleShortcut) + return () => window.removeEventListener('keydown', handleShortcut) + }, []) + + const isActive = (match: (typeof mainLinks)[number]['match']) => { + if (match === 'dashboard') return pathname === '/admin' && section !== 'analytics' + if (match === 'analytics') return pathname === '/admin' && section === 'analytics' + if (match === 'orders') return pathname.startsWith('/admin/collections/orders') + if (match === 'customers') return pathname.startsWith('/admin/collections/users') + if (match === 'inventory') { + return pathname.startsWith('/admin/collections/products') && hasInventoryFilter + } + if (match === 'products') { + return pathname.startsWith('/admin/collections/products') && !hasInventoryFilter + } + if (match === 'marketing') { + return ( + pathname.startsWith('/admin/collections/forms') || + pathname.startsWith('/admin/collections/form-submissions') + ) + } + return [ + '/admin/collections/pages', + '/admin/collections/categories', + '/admin/collections/tags', + '/admin/collections/media', + ].some((path) => pathname.startsWith(path)) + } + + const closeMobileNav = () => setNavOpen(false) + + const submitSearch = (event: React.FormEvent) => { + event.preventDefault() + const query = new FormData(event.currentTarget).get('query')?.toString().trim() + if (!query) return + router.push(`/admin/collections/products?search=${encodeURIComponent(query)}`) + closeMobileNav() + } + + const displayName = user?.name?.trim() || user?.email?.split('@')[0] || 'Administrator' + + return ( + + ) +} diff --git a/src/components/admin/AdminStyles/index.tsx b/src/components/admin/AdminStyles/index.tsx index 0a69560..7e3e462 100644 --- a/src/components/admin/AdminStyles/index.tsx +++ b/src/components/admin/AdminStyles/index.tsx @@ -4,6 +4,7 @@ import React from 'react' import { AdminNavigationProgress } from '../AdminNavigationProgress' +import './shell.scss' import './nav-icons.scss' import './fields.scss' import './media-grid.scss' diff --git a/src/components/admin/AdminStyles/media-grid.scss b/src/components/admin/AdminStyles/media-grid.scss index ca8ee0f..127162f 100644 --- a/src/components/admin/AdminStyles/media-grid.scss +++ b/src/components/admin/AdminStyles/media-grid.scss @@ -56,9 +56,9 @@ grid-template-rows: auto auto 1fr; min-width: 0; overflow: hidden; - border: 1px solid var(--theme-elevation-150); - border-radius: var(--style-radius-s); - background: var(--theme-elevation-0) !important; + border: 1px solid var(--admin-edge, var(--theme-elevation-150)); + border-radius: var(--admin-radius-panel, 10px); + background: var(--admin-panel, var(--theme-elevation-0)) !important; transition: border-color 140ms ease, background-color 140ms ease; &:hover { diff --git a/src/components/admin/AdminStyles/shell.scss b/src/components/admin/AdminStyles/shell.scss new file mode 100644 index 0000000..d83a898 --- /dev/null +++ b/src/components/admin/AdminStyles/shell.scss @@ -0,0 +1,638 @@ +/** + * Marisol admin shell. + * + * Payload remains responsible for every control and layout state. This layer + * gives those primitives one coherent visual system: a dark navigation plane, + * an inset workspace, and quieter tonal grouping inside that workspace. + */ + +.template-default { + --admin-frame: #e8e8e4; + --admin-canvas: #fafaf8; + --admin-panel: #fff; + --admin-panel-muted: #f4f4f1; + --admin-edge: rgb(32 34 30 / 0.1); + --admin-edge-strong: rgb(32 34 30 / 0.16); + --admin-sidebar: #161714; + --admin-sidebar-active: #2a2b27; + --admin-sidebar-hover: #22231f; + --admin-sidebar-text: #e9e9e4; + --admin-sidebar-muted: #96988f; + --admin-radius-shell: 16px; + --admin-radius-panel: 12px; + --admin-radius-control: 8px; + --style-radius-s: var(--admin-radius-control); + + min-height: 100vh; + gap: 8px; + padding: 8px; + background: var(--admin-frame); +} + +:root[data-theme='dark'] .template-default { + --admin-frame: #090a09; + --admin-canvas: #131412; + --admin-panel: #191a18; + --admin-panel-muted: #20211e; + --admin-edge: rgb(255 255 248 / 0.09); + --admin-edge-strong: rgb(255 255 248 / 0.15); + --admin-sidebar: #0e0f0d; + --admin-sidebar-active: #292a26; + --admin-sidebar-hover: #20211e; + --admin-sidebar-text: #f0f0eb; + --admin-sidebar-muted: #92948b; +} + +@media (min-width: 1025px) { + .template-default--nav-open, + .template-default:not(.template-default--nav-open) { + grid-template-columns: 248px minmax(0, 1fr) !important; + } + + .template-default .nav { + width: 248px !important; + min-width: 248px !important; + opacity: 1 !important; + transform: none !important; + visibility: visible !important; + } + + .template-default .custom-admin-nav { + display: flex !important; + } +} + +.template-default__wrap { + min-width: 0; + min-height: calc(100vh - 16px); + overflow: clip; + border: 1px solid var(--admin-edge); + border-radius: var(--admin-radius-shell); + background: var(--admin-canvas); + box-shadow: 0 8px 20px rgb(28 30 26 / 0.08); + --gutter-h: clamp(1.5rem, 2.4vw, 2.75rem); +} + +/* The dashboard is the admin home, so its content starts where the reference starts. */ +.template-default__wrap:has(.store-overview) .app-header { + display: none; +} + +.template-default__wrap:has(.store-overview) > .gutter { + padding-top: 2rem; +} + +:root[data-theme='dark'] .template-default__wrap { + box-shadow: 0 8px 22px rgb(0 0 0 / 0.22); +} + +/* Navigation: one quiet plane, with state communicated by tone and weight. */ +.template-default .nav { + position: sticky; + top: 8px; + height: calc(100vh - 16px); + color: var(--admin-sidebar-text); + border-radius: var(--admin-radius-shell); + background: var(--admin-sidebar); +} + +.template-default__nav-toggler-wrapper { + display: none; +} + +.template-default__nav-toggler { + color: var(--admin-sidebar-text); + border: 1px solid rgb(255 255 255 / 0.14); + border-radius: var(--admin-radius-control); + background: rgb(255 255 255 / 0.06); +} + +.template-default__nav-toggler .hamburger { + color: var(--admin-sidebar-text); + background: transparent; +} + +.template-default__nav-toggler .stroke { + stroke: currentColor; +} + +.template-default__nav-toggler:hover { + background: rgb(255 255 255 / 0.11); +} + +/* Workspace chrome */ +.template-default .app-header { + min-height: 3.75rem; + background: transparent; +} + +.template-default .app-header__bg { + border-bottom: 1px solid var(--admin-edge); + background: color-mix(in srgb, var(--admin-canvas) 92%, transparent); + backdrop-filter: blur(12px); +} + +.template-default .app-header__content { + min-height: 3.75rem; +} + +.template-default .app-header__account { + border-radius: 50%; +} + +.template-default h1, +.template-default .list-header__title { + letter-spacing: -0.025em; +} + +.template-default .doc-header, +.template-default .list-header { + padding-top: 1.2rem; +} + +.template-default .doc-header__title, +.template-default .list-header__title { + font-size: clamp(1.75rem, 2.5vw, 2.3rem); + font-weight: 650; + line-height: 1.08; +} + +/* Controls: compact, tactile, and consistent without turning everything into pills. */ +.template-default .btn, +.template-default .popup-button, +.template-default button[class*='button'] { + border-radius: var(--admin-radius-control); +} + +.template-default .btn--style-primary { + --bg-color: #30382a; + --hover-bg: #232a1f; + --color: #fff; +} + +:root[data-theme='dark'] .template-default .btn--style-primary { + --bg-color: #d9dfcf; + --hover-bg: #eef2e8; + --color: #1b1d19; +} + +.template-default .btn--style-subtle, +.template-default .btn--style-tab, +.template-default .popup-button--background { + --bg-color: var(--admin-panel-muted); + --hover-bg: color-mix(in srgb, var(--admin-panel-muted) 82%, var(--theme-text)); +} + +.template-default input:not([type='checkbox']):not([type='radio']), +.template-default textarea, +.template-default .rs__control { + border-radius: var(--admin-radius-control); + background: var(--admin-panel-muted); +} + +.template-default input:not([type='checkbox']):not([type='radio']):focus, +.template-default textarea:focus, +.template-default .rs__control--is-focused { + outline: 2px solid color-mix(in srgb, #4c5a40 55%, transparent); + outline-offset: 1px; +} + +.template-default .popup__content, +.template-default .rs__menu { + border: 1px solid var(--admin-edge-strong); + border-radius: var(--admin-radius-control); + background: var(--admin-panel); + box-shadow: 0 8px 18px rgb(24 26 22 / 0.12); +} + +/* Collection lists */ +.template-default .collection-list { + padding-bottom: 2rem; +} + +.template-default .list-header__title-and-actions { + align-items: center; +} + +.template-default .list-controls { + margin-top: 1.15rem; + padding: 0.45rem; + border: 1px solid var(--admin-edge); + border-radius: var(--admin-radius-panel); + background: var(--admin-panel); +} + +.template-default .search-filter__input, +.template-default .search-bar { + background: transparent; +} + +.template-default .collection-list:not(.collection-list--media) .table-wrap { + overflow: auto; + margin-top: 0.9rem; + border: 1px solid var(--admin-edge); + border-radius: var(--admin-radius-panel); + background: var(--admin-panel); +} + +.template-default .collection-list:not(.collection-list--media) .table table { + border-collapse: separate; + border-spacing: 0; +} + +.template-default .collection-list:not(.collection-list--media) .table thead th { + padding-top: 0.9rem; + padding-bottom: 0.75rem; + color: var(--theme-elevation-500); + background: var(--admin-panel); +} + +.template-default .collection-list:not(.collection-list--media) .table tbody tr td { + border-top: 1px solid var(--admin-edge); + background: var(--admin-panel) !important; + transition: background-color 140ms ease; +} + +.template-default .collection-list:not(.collection-list--media) .table tbody tr:hover td { + background: var(--admin-panel-muted) !important; +} + +.template-default .page-controls { + padding-top: 1rem; +} + +/* Edit and global forms */ +.template-default .doc-controls { + border-block: 1px solid var(--admin-edge); + background: var(--admin-panel); +} + +.template-default .doc-tabs { + background: transparent; +} + +.template-default .document-fields__fields, +.template-default .document-fields__sidebar-fields { + gap: 1.25rem; +} + +.template-default .document-fields__sidebar-wrap { + border-radius: var(--admin-radius-panel); + background: var(--admin-panel-muted); +} + +.template-default .collapsible--style-default, +.template-default .array-field, +.template-default .blocks-field { + border-color: var(--admin-edge); + border-radius: var(--admin-radius-panel); + background: var(--admin-panel); +} + +.template-default .collapsible__header-wrap, +.template-default .array-field__header-wrap, +.template-default .blocks-field__header-wrap { + border-radius: var(--admin-radius-panel) var(--admin-radius-panel) 0 0; + background: var(--admin-panel-muted); +} + +.template-default .array-field__row, +.template-default .blocks-field__row { + border-color: var(--admin-edge); + border-radius: var(--admin-radius-control); +} + +.template-default .upload-card, +.template-default .file-details { + border-radius: var(--admin-radius-panel); +} + +/* Product editor: the commerce-specific workspace mirrors the reference layout. */ +.template-default:has(.collection-edit--products) .doc-header__title { + font-size: 0; +} + +.template-default:has(.collection-edit--products) .doc-header__title::before { + display: block; + color: var(--theme-elevation-950); + content: 'Edit Product'; + font-size: clamp(1.8rem, 2.5vw, 2.35rem); + font-weight: 680; + letter-spacing: -0.035em; + line-height: 1.08; +} + +.template-default:has(.collection-edit--products) .doc-header__title::after { + display: block; + margin-top: 0.35rem; + color: var(--theme-elevation-500); + content: 'Make changes to your product details, variants, and inventory.'; + font-size: 0.96rem; + font-weight: 400; + letter-spacing: 0; + line-height: 1.4; +} + +.template-default:has(.collection-edit--products) .doc-tabs, +.collection-edit--products .doc-controls__meta, +.collection-edit--products .live-preview-toggler { + display: none; +} + +.template-default:has(.collection-edit--products) .doc-header__header { + align-items: flex-start; + padding-bottom: 1.15rem; +} + +.collection-edit--products .doc-controls { + position: absolute; + z-index: 3; + top: 4.9rem; + right: var(--gutter-h); + width: auto; + border: 0; + background: transparent; +} + +.collection-edit--products .doc-controls__wrapper, +.collection-edit--products .doc-controls__content { + width: auto; + min-height: 0; + padding: 0; +} + +.collection-edit--products .doc-controls__controls { + gap: 0.55rem; +} + +.collection-edit--products .preview-btn, +.collection-edit--products .doc-controls__popup .popup-button { + min-height: 2.55rem; + border: 1px solid var(--admin-edge-strong); + border-radius: var(--admin-radius-control); + background: var(--admin-panel); +} + +.collection-edit--products .form-submit .btn { + min-height: 2.55rem; + padding-inline: 1.15rem; +} + +.collection-edit--products .document-fields { + border-top: 1px solid var(--admin-edge); +} + +.collection-edit--products .document-fields__main { + align-items: flex-start; +} + +.collection-edit--products .document-fields__edit { + padding-top: 1.35rem; + padding-bottom: 2.5rem; +} + +.collection-edit--products .document-fields__fields { + gap: 1rem; +} + +.collection-edit--products .product-editor__section > .collapsible { + overflow: hidden; + border: 1px solid var(--admin-edge); + border-radius: var(--admin-radius-panel); + background: var(--admin-panel); +} + +.collection-edit--products .product-editor__section .collapsible__header-wrap { + min-height: 3.15rem; + padding-inline: 1rem; + background: var(--admin-panel); +} + +.collection-edit--products .product-editor__section .collapsible-field__row-label-wrap { + background: transparent; +} + +.collection-edit--products .product-editor__section .collapsible__toggle-wrap, +.collection-edit--products .product-editor__sidebar-card .collapsible__toggle-wrap { + background: var(--admin-panel); +} + +.collection-edit--products .product-editor__section .row-label { + color: var(--theme-elevation-950); + font-size: 1.08rem; + font-weight: 650; +} + +.collection-edit--products .product-editor__section > .collapsible > .rah-static > .collapsible__content { + padding: 0 1rem 1rem; +} + +.collection-edit--products .product-editor__basic .rich-text-lexical__wrap { + min-height: 8.25rem; + border-color: var(--admin-edge-strong); + border-radius: var(--admin-radius-control); +} + +.collection-edit--products .product-editor__basic .fixed-toolbar { + border-radius: var(--admin-radius-control) var(--admin-radius-control) 0 0; +} + +.collection-edit--products .product-editor__media .array-field { + padding: 0; + border: 0; +} + +.collection-edit--products .product-editor__media .array-field__header-wrap { + padding-inline: 0; + background: transparent; +} + +.collection-edit--products .product-editor__media .array-field__draggable-rows { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.55rem; +} + +.collection-edit--products .product-editor__media .array-field__row { + min-width: 0; + margin: 0; + overflow: hidden; + border: 1px solid var(--admin-edge); + border-radius: 9px; +} + +.collection-edit--products .product-editor__media .array-field__row-header { + display: none; +} + +.collection-edit--products .product-editor__media .array-field__row > .rah-static > .collapsible__content { + padding: 0; +} + +.collection-edit--products .product-editor__media .array-field__fields { + gap: 0; + padding: 0; +} + +.collection-edit--products .product-editor__media .field-type.upload > .field-label, +.collection-edit--products .product-editor__media .field-type.relationship { + display: none; +} + +.collection-edit--products .product-editor__media .upload-field-card { + min-height: 9.5rem; + border: 0; + border-radius: 0; + background: var(--admin-panel-muted); +} + +.collection-edit--products .product-editor__media .upload-relationship-details__imageAndDetails { + display: block; + height: 9.5rem; +} + +.collection-edit--products .product-editor__media .upload-relationship-details__thumbnail, +.collection-edit--products .product-editor__media .thumbnail, +.collection-edit--products .product-editor__media .thumbnail img { + width: 100%; + height: 100%; + max-width: none; + object-fit: cover; +} + +.collection-edit--products .product-editor__media .upload-relationship-details__details { + display: none; +} + +.collection-edit--products .product-editor__media .upload-relationship-details__actions { + position: absolute; + top: 0.35rem; + right: 0.35rem; + padding: 0.1rem; + border-radius: 7px; + background: color-mix(in srgb, var(--admin-panel) 90%, transparent); +} + +.collection-edit--products .document-fields__sidebar-wrap { + border-left: 1px solid var(--admin-edge); + border-radius: 0; + background: var(--admin-canvas); +} + +.collection-edit--products .document-fields__sidebar { + padding-top: 1.35rem; +} + +.collection-edit--products .document-fields__sidebar-fields { + gap: 1rem; +} + +.collection-edit--products .product-editor__sidebar-card > .collapsible { + overflow: hidden; + border: 1px solid var(--admin-edge); + border-radius: var(--admin-radius-panel); + background: var(--admin-panel); +} + +.collection-edit--products .product-editor__sidebar-card .collapsible__header-wrap { + min-height: 3.2rem; + padding-inline: 1rem; + background: var(--admin-panel); +} + +.collection-edit--products .product-editor__sidebar-card .collapsible-field__row-label-wrap { + background: transparent; +} + +.collection-edit--products .product-editor__sidebar-card .row-label { + color: var(--theme-elevation-950); + font-size: 1.08rem; + font-weight: 650; +} + +.collection-edit--products .product-editor__sidebar-card > .collapsible > .rah-static > .collapsible__content { + padding: 0 1rem 1rem; +} + +.collection-edit--products .product-editor__sidebar-card .render-fields { + gap: 0.9rem; +} + +.collection-edit--products .product-inventory-hint { + margin: 0; + color: var(--theme-elevation-500); + font-size: 0.9rem; + line-height: 1.45; +} + +.collection-edit--products .product-editor__pricing .field-description { + display: none; +} + +.collection-edit--products .product-editor__pricing .field-type.group { + padding-top: 0.8rem; + border-top: 1px solid var(--admin-edge); +} + +.collection-edit--products .product-editor__pricing .field-type.group:first-child { + padding-top: 0; + border-top: 0; +} + +@media (max-width: 1120px) { + .collection-edit--products .doc-controls { position: static; width: 100%; border-block: 1px solid var(--admin-edge); background: var(--admin-panel); } + .collection-edit--products .doc-controls__wrapper { width: 100%; padding: 0.65rem var(--gutter-h); } + .collection-edit--products .product-editor__media .array-field__draggable-rows { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 760px) { + .collection-edit--products .product-editor__media .array-field__draggable-rows { grid-template-columns: minmax(0, 1fr); } + .collection-edit--products .product-editor__basic .row__fields { flex-direction: column; } + .collection-edit--products .product-editor__basic .row__fields > * { width: 100% !important; } +} + +@media (max-width: 1024px) { + .template-default { + display: block; + gap: 0; + padding: 0; + } + + .template-default__wrap { + width: 100%; + min-height: 100vh; + border: 0; + border-radius: 0; + box-shadow: none; + } + + .template-default .nav { + top: 0; + height: 100vh; + border-radius: 0 var(--admin-radius-shell) var(--admin-radius-shell) 0; + } + + .template-default__nav-toggler-wrapper { + display: block; + position: fixed; + top: 1rem; + right: auto; + left: 1rem; + width: 2.5rem; + height: 2.5rem; + z-index: 4; + } + + .template-default__nav-toggler { + width: 2.5rem; + height: 2.5rem; + padding: 0.65rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .template-default .nav__link, + .template-default .collection-list .table tbody td { + transition: none; + } +} diff --git a/src/components/admin/AttentionQueue/index.scss b/src/components/admin/AttentionQueue/index.scss index 208b04d..a62f2f0 100644 --- a/src/components/admin/AttentionQueue/index.scss +++ b/src/components/admin/AttentionQueue/index.scss @@ -1,109 +1,52 @@ .attention-queue { - padding: 1.15rem 1.25rem; - border: 1px solid var(--theme-elevation-100); - border-radius: var(--style-radius-s, 3px); - background: var(--theme-elevation-0); + padding: 1.15rem 1.25rem 1.25rem; + border: 1px solid var(--admin-edge, var(--theme-elevation-100)); + border-radius: var(--admin-radius-panel, 12px); + background: var(--admin-panel, var(--theme-elevation-0)); &__head { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - margin-bottom: 0.85rem; - - h3 { - display: flex; - align-items: center; - gap: 0.5rem; - margin: 0; - font-size: 1.08rem; - font-weight: 600; - } - - > span { - color: var(--theme-elevation-450); - font-size: 0.925rem; - font-variant-numeric: tabular-nums; - } - } - - &__icon { - flex: 0 0 auto; - color: var(--theme-elevation-500); + display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.85rem; + h3 { display: flex; align-items: center; gap: 0.55rem; margin: 0; font-size: 1.08rem; font-weight: 650; } + > span { color: var(--theme-elevation-450); font-size: 0.88rem; font-variant-numeric: tabular-nums; } + } + &__icon { width: 1rem; height: 1rem; color: var(--theme-elevation-500); } + &__badge { + display: grid; min-width: 1.5rem; height: 1.5rem; padding: 0 0.35rem; place-items: center; + border-radius: 999px; color: #fff; background: #d72c25; font-size: 0.78rem; font-weight: 700; + font-variant-numeric: tabular-nums; } - &__items { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.35rem; + display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; + overflow: hidden; border-radius: 10px; background: var(--admin-panel-muted, var(--theme-elevation-50)); } - &__item { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; - align-items: center; - gap: 0.6rem; - min-width: 0; - padding: 0.52rem 0.7rem; - border-radius: var(--style-radius-s, 3px); - color: var(--theme-elevation-850); - background: var(--theme-elevation-50); - text-decoration: none; - transition: background-color 120ms ease; - - &:hover { - background: var(--theme-elevation-100); - } - - &:focus-visible { - outline: 2px solid var(--theme-elevation-700); - outline-offset: 2px; - } - - &--urgent .attention-queue__count { - color: var(--theme-error-500); - } + display: grid; grid-template-columns: 2rem minmax(0, 1fr) auto auto; align-items: center; + gap: 0.8rem; min-width: 0; min-height: 5.35rem; padding: 0.9rem 1rem; + border-left: 1px solid var(--admin-edge); color: var(--theme-elevation-850); + text-decoration: none; transition: background-color 120ms ease; + &:first-child { border-left: 0; } + &:hover { background: color-mix(in srgb, var(--admin-panel-muted) 72%, var(--theme-text)); } + &:focus-visible { position: relative; z-index: 1; outline: 2px solid var(--theme-elevation-700); outline-offset: -2px; } + &--urgent .attention-queue__count { color: var(--theme-error-500); } + } + &__item-icon { + display: grid; width: 2rem; height: 2rem; place-items: center; border-radius: 50%; + color: #5e625c; background: #e9eae6; + svg { width: 1.05rem; height: 1.05rem; } + &--danger { color: #fff; background: #d72c25; } + &--warning { color: #fff; background: #d9900b; } } - &__copy { - display: flex; - flex-direction: column; - gap: 0.08rem; - min-width: 0; - - strong { - color: var(--theme-elevation-900); - font-size: 1rem; - font-weight: 600; - } - - > span { - color: var(--theme-elevation-500); - font-size: 0.925rem; - line-height: 1.25; - } - } - - &__count { - color: var(--theme-elevation-850); - font-size: 1rem; - font-weight: 600; - font-variant-numeric: tabular-nums; - } - - &__arrow { - color: var(--theme-elevation-450); - font-size: 0.85rem; - } - - &__empty { - margin: 0; - color: var(--theme-elevation-500); - } - - @media (max-width: 62rem) { - &__items { - grid-template-columns: minmax(0, 1fr); - } + display: flex; min-width: 0; flex-direction: column; gap: 0.2rem; + strong { color: var(--theme-elevation-900); font-size: 0.98rem; font-weight: 650; } + > span { color: var(--theme-elevation-500); font-size: 0.84rem; line-height: 1.3; } } + &__count { font-size: 1.1rem; font-weight: 650; font-variant-numeric: tabular-nums; } + &__arrow { color: var(--theme-elevation-450); } + &__empty { margin: 0; color: var(--theme-elevation-500); } +} +@media (max-width: 68rem) { + .attention-queue__items { grid-template-columns: minmax(0, 1fr); } + .attention-queue__item { border-top: 1px solid var(--admin-edge); border-left: 0; } + .attention-queue__item:first-child { border-top: 0; } } diff --git a/src/components/admin/AttentionQueue/index.tsx b/src/components/admin/AttentionQueue/index.tsx index 6e61298..5090af7 100644 --- a/src/components/admin/AttentionQueue/index.tsx +++ b/src/components/admin/AttentionQueue/index.tsx @@ -2,7 +2,13 @@ import Link from 'next/link' import React from 'react' import type { AttentionCounts } from '../Dashboard/queries' -import { IconAttention } from '../icons' +import { + IconAttention, + IconCheckout, + IconFulfilment, + IconPaymentWarning, + IconStock, +} from '../icons' import './index.scss' @@ -31,7 +37,9 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { href: filteredHref(adminPath, 'orders', [ { field: 'status', operator: 'equals', value: 'processing' }, ]), + Icon: IconFulfilment, label: 'Orders to process', + tone: 'danger', }, { count: counts.failedPayments, @@ -39,6 +47,7 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { href: filteredHref(adminPath, 'transactions', [ { field: 'status', operator: 'equals', value: 'failed' }, ]), + Icon: IconPaymentWarning, label: 'Failed payments', tone: 'urgent', }, @@ -48,7 +57,9 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { href: filteredHref(adminPath, 'transactions', [ { field: 'status', operator: 'equals', value: 'pending' }, ]), + Icon: IconPaymentWarning, label: 'Stalled payments', + tone: 'warning', }, { count: counts.incompleteCheckouts, @@ -56,7 +67,9 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { href: filteredHref(adminPath, 'carts', [ { field: 'purchasedAt', operator: 'exists', value: 'false' }, ]), + Icon: IconCheckout, label: 'Incomplete checkouts', + tone: 'neutral', }, { count: counts.outOfStockProducts, @@ -66,7 +79,9 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { { field: 'inventory', operator: 'less_than_equal', value: '0' }, { field: '_status', operator: 'equals', value: 'published' }, ]), + Icon: IconStock, label: 'Products out of stock', + tone: 'warning', }, { count: counts.outOfStockVariants, @@ -75,9 +90,11 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { { field: 'inventory', operator: 'less_than_equal', value: '0' }, { field: '_status', operator: 'equals', value: 'published' }, ]), + Icon: IconStock, label: 'Variants out of stock', + tone: 'warning', }, - ].filter((item) => item.count > 0) + ].filter((item) => item.count > 0).slice(0, 3) const total = items.reduce((sum, item) => sum + item.count, 0) return ( @@ -86,6 +103,7 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => {

Needs attention + {items.length}

{total > 0 && {total} open} @@ -102,6 +120,9 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => { href={item.href} key={item.label} > + + + {item.label} {item.detail} diff --git a/src/components/admin/Dashboard/index.scss b/src/components/admin/Dashboard/index.scss index c8668cf..dc0efd2 100644 --- a/src/components/admin/Dashboard/index.scss +++ b/src/components/admin/Dashboard/index.scss @@ -1,183 +1,73 @@ -/** - * Store overview, shown above the collection index on the admin home. - * - * Drawn entirely from Payload's own theme variables so it reads as part of the - * panel rather than something bolted on, and follows the light and dark themes - * without a second palette. - */ - .store-overview { display: flex; flex-direction: column; - gap: 1.5rem; - margin-bottom: 2.5rem; - + width: 100%; + gap: 1rem; + margin: 0 0 2.5rem; - &__greeting { + &__topline { display: flex; - flex-direction: column; - gap: 0.2rem; - margin-bottom: 0.25rem; - } - - &__hello { - margin: 0; - font-size: 1.85rem; - font-weight: 600; - letter-spacing: -0.01em; + align-items: flex-end; + justify-content: space-between; + gap: 1.5rem; + padding: 0 0 0.25rem; } - &__date { - margin: 0; - font-size: 1rem; - color: var(--theme-elevation-450); - } + &__greeting { display: flex; flex-direction: column; gap: 0.22rem; } + &__hello { margin: 0; color: var(--theme-elevation-950); font-size: clamp(2rem, 2.5vw, 2.55rem); font-weight: 700; letter-spacing: -0.04em; } + &__date { margin: 0; color: var(--theme-elevation-500); font-size: 0.98rem; } - /* The trend needs room to read; best sellers is a list and does not. */ &__split { display: grid; - grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr); - gap: 1.25rem; + grid-template-columns: minmax(0, 1.55fr) minmax(22rem, 0.82fr); + gap: 0.9rem; } - @media (max-width: 68rem) { - &__split { grid-template-columns: minmax(0, 1fr); } - } - - &__heading { - margin: 0; - font-size: 1.5rem; - font-weight: 600; - } - - &__stats { + &__lower { display: grid; - grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); - gap: 1px; - background: var(--theme-elevation-100); - border: 1px solid var(--theme-elevation-100); - border-radius: 10px; - overflow: hidden; - } - - &__stat { - display: flex; - flex-direction: column; - gap: 0.35rem; - padding: 1.1rem 1.25rem; - background: var(--theme-elevation-0); - } - - &__stat-label { - font-size: 1rem; - letter-spacing: 0.04em; - text-transform: uppercase; - color: var(--theme-elevation-500); - } - - &__stat-value { - font-size: 1.9rem; - line-height: 1.05; - font-weight: 600; - font-variant-numeric: tabular-nums; - } - - &__stat-hint { - font-size: 0.925rem; - color: var(--theme-elevation-450); - } - - /** - * No accent rail. A coloured bar down one edge is decoration standing in for - * hierarchy, and a radius curving a 3px bar reads as broken. This is the same - * panel as everything else, set apart by its surface and the weight of the - * opening line. - */ - &__notice { - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 1rem 1.15rem; - border: 1px solid var(--theme-elevation-150); - border-radius: 10px; - background: var(--theme-elevation-50); - - p { - margin: 0; - max-width: 68ch; - color: var(--theme-elevation-650); - } - } - - &__columns { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); - gap: 1.25rem; + grid-template-columns: minmax(31rem, 1.7fr) minmax(15rem, 0.78fr) minmax(16rem, 0.82fr); + gap: 0.9rem; + align-items: stretch; } &__panel { - border: 1px solid var(--theme-elevation-100); - border-radius: 10px; + border: 1px solid var(--admin-edge, var(--theme-elevation-100)); + border-radius: var(--admin-radius-panel, 12px); padding: 1.15rem 1.25rem; - background: var(--theme-elevation-0); + background: var(--admin-panel, var(--theme-elevation-0)); } &__panel-heading { - margin: 0 0 0.85rem; - font-size: 1.08rem; - font-weight: 600; - display: flex; - align-items: center; - gap: 0.5rem; + display: flex; align-items: center; gap: 0.5rem; + margin: 0 0 0.7rem; font-size: 1.08rem; font-weight: 650; } - - &__panel-icon { color: var(--theme-elevation-500); flex: 0 0 auto; } - - &__panel-note { - font-size: 0.925rem; - font-weight: 400; - color: var(--theme-elevation-450); - } - - &__rows { - margin: 0; - display: flex; - flex-direction: column; - } - + &__panel-icon { width: 1rem; height: 1rem; color: var(--theme-elevation-500); } + &__panel-note { color: var(--theme-elevation-450); font-size: 0.84rem; font-weight: 400; } + &__rows { display: flex; flex-direction: column; margin: 0; } &__row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 1rem; - padding: 0.5rem 0; - border-bottom: 1px solid var(--theme-elevation-100); - - &:last-child { - border-bottom: 0; - padding-bottom: 0; - } - - dt { - color: var(--theme-elevation-650); - min-width: 0; - overflow-wrap: anywhere; - } - - dd { - margin: 0; - font-variant-numeric: tabular-nums; - font-weight: 600; - white-space: nowrap; - } - } + display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; + min-height: 3.35rem; border-bottom: 1px solid var(--admin-edge); + &:last-child { border-bottom: 0; } + dt { display: flex; min-width: 0; align-items: center; gap: 0.65rem; color: var(--theme-elevation-750); } + dt img, .store-overview__row-image { width: 2.25rem; height: 2.25rem; flex: 0 0 auto; border-radius: 7px; object-fit: cover; background: var(--admin-panel-muted); } + dt span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + dd { margin: 0; color: #b47500; font-size: 0.9rem; font-weight: 650; font-variant-numeric: tabular-nums; white-space: nowrap; } + } + &__out { color: #c93c35 !important; } + &__empty { margin: 0; color: var(--theme-elevation-450); } +} +@media (max-width: 82rem) { + .store-overview__lower { grid-template-columns: minmax(0, 1.4fr) minmax(16rem, 0.7fr); } + .store-overview__lower .store-overview__panel { grid-column: 1 / -1; } +} - &__out { - color: var(--theme-error-500, #a3231e); - } +@media (max-width: 68rem) { + .store-overview__topline { align-items: flex-start; flex-direction: column; } + .store-overview__split, .store-overview__lower { grid-template-columns: minmax(0, 1fr); } + .store-overview__lower .store-overview__panel { grid-column: auto; } +} - &__empty { - margin: 0; - color: var(--theme-elevation-450); - } +@media (max-width: 42rem) { + .store-overview { gap: 0.85rem; } + .store-overview__topline { gap: 1rem; } } diff --git a/src/components/admin/Dashboard/index.tsx b/src/components/admin/Dashboard/index.tsx index 1008552..719a6db 100644 --- a/src/components/admin/Dashboard/index.tsx +++ b/src/components/admin/Dashboard/index.tsx @@ -1,16 +1,18 @@ import config from '@payload-config' import { getPayload } from 'payload' import React from 'react' +import Image from 'next/image' import { AnalyticsControls } from '../AnalyticsControls' import { AttentionQueue } from '../AttentionQueue' -import { IconAverage, IconOrders, IconProducts, IconRevenue, IconStock } from '../icons' +import { IconAverage, IconCustomers, IconOrders, IconRevenue, IconStock } from '../icons' import { KpiStrip, type Kpi } from '../KpiStrip' import { OrderHealth } from '../OrderHealth' import { PopularProducts, type PopularProduct } from '../PopularProducts' import { RecentOrders } from '../RecentOrders' import { RevenueTrend } from '../RevenueTrend' import { DEFAULT_CURRENCY_CODE } from '@/currencies' +import type { Media, Product } from '@/payload-types' import { addCalendarDays, dateTimeInZone, @@ -32,7 +34,7 @@ const money = (minor: number, currency: string) => const change = (now: number, before: number) => before > 0 ? ((now - before) / before) * 100 : undefined -type StockRow = { title: string; qty: number } +type StockRow = { alt?: string; image?: string; title: string; qty: number } type DashboardFilters = { comparison?: string @@ -79,17 +81,31 @@ async function getOverview(filters: DashboardFilters) { const popularProducts = popularIDs.length ? await payload.find({ collection: 'products', - depth: 0, + depth: 1, limit: popularIDs.length, where: { id: { in: popularIDs } }, }) : { docs: [] } - const titleFor = new Map( - popularProducts.docs.map((product) => [product.id, String(product.title ?? 'Untitled')]), - ) + const mediaFor = (product: Product) => { + const media = product.gallery?.[0]?.image + return typeof media === 'object' ? (media as Media) : undefined + } + const displayPrice = (product: Product) => { + const value = product[`priceIn${currency}` as keyof Product] + return typeof value === 'number' ? money(value, currency) : undefined + } + const productFor = new Map(popularProducts.docs.map((product) => [product.id, product])) const popular: PopularProduct[] = orderData.popular.flatMap((item) => { - const title = titleFor.get(item.productID) - return title ? [{ title, units: item.units }] : [] + const product = productFor.get(item.productID) + if (!product) return [] + const image = mediaFor(product) + return [{ + alt: image?.alt, + image: image?.sizes?.thumbnail?.url || image?.thumbnailURL || image?.url || undefined, + price: displayPrice(product), + title: String(product.title ?? 'Untitled'), + units: item.units, + }] }) /** @@ -100,7 +116,7 @@ async function getOverview(filters: DashboardFilters) { const [simple, variants] = await Promise.all([ payload.find({ collection: 'products', - depth: 0, + depth: 1, limit: 100, where: { and: [{ enableVariants: { not_equals: true } }, { inventory: { less_than: LOW_STOCK_AT } }], @@ -114,11 +130,25 @@ async function getOverview(filters: DashboardFilters) { }), ]) const lowStock: StockRow[] = [ - ...simple.docs.map((p) => ({ - title: String(p.title ?? 'Untitled'), - qty: Number(p.inventory ?? 0), - })), + ...simple.docs.map((p) => { + const image = mediaFor(p) + return { + alt: image?.alt, + image: image?.sizes?.thumbnail?.url || image?.thumbnailURL || image?.url || undefined, + title: String(p.title ?? 'Untitled'), + qty: Number(p.inventory ?? 0), + } + }), ...variants.docs.map((v) => ({ + ...(typeof v.product === 'object' && v.product + ? (() => { + const image = mediaFor(v.product) + return { + alt: image?.alt, + image: image?.sizes?.thumbnail?.url || image?.thumbnailURL || image?.url || undefined, + } + })() + : {}), title: String((typeof v.product === 'object' && v.product?.title) || v.title || 'Variant'), qty: Number(v.inventory ?? 0), })), @@ -181,76 +211,57 @@ export const Dashboard: React.FC<{ const kpis: Kpi[] = [ { Icon: IconRevenue, - label: 'Net sales', + label: 'Total revenue', value: money(s.currentRevenue, s.currency), delta: s.revenueChange, compare: s.window.comparisonLabel, }, { Icon: IconOrders, - label: 'Orders', + label: 'Total orders', value: s.currentOrders.toLocaleString('en-US'), delta: s.ordersChange, compare: s.window.comparisonLabel, }, + { + Icon: IconCustomers, + label: 'Total customers', + value: s.customers.toLocaleString('en-US'), + compare: `${s.products} products live`, + }, { Icon: IconAverage, - label: 'Average order', + label: 'Average order value', value: money(s.currentAov, s.currency), delta: s.aovChange, compare: s.window.comparisonLabel, }, - { - Icon: IconProducts, - label: 'Products live', - value: String(s.products), - compare: `${s.customers} customers`, - }, ] return (
-
-

Hey{name ? `, ${name}` : ' there'}

-

- {new Date().toLocaleDateString('en-US', { - weekday: 'long', - day: 'numeric', - month: 'long', - year: 'numeric', - timeZone: s.timeZone, - })} -

-
+
+
+

Good morning{name ? `, ${name}` : ''}

+

Here’s what’s happening with your store today.

+
- + +
- - -
+
-
+
+
@@ -278,7 +290,14 @@ export const Dashboard: React.FC<{
{s.lowStock.slice(0, 6).map((row, i) => (
-
{row.title}
+
+ {row.image ? ( + {row.alt + ) : ( +
{row.qty === 0 ? 'Out of stock' : `${row.qty} left`}
diff --git a/src/components/admin/DashboardNavLink/index.tsx b/src/components/admin/DashboardNavLink/index.tsx deleted file mode 100644 index 0c3879d..0000000 --- a/src/components/admin/DashboardNavLink/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client' - -import Link from 'next/link' -import { usePathname } from 'next/navigation' -import React from 'react' - -/** - * A "Dashboard" entry at the top of the sidebar. - * - * Payload builds its nav from collections and globals, so the admin home has no - * link of its own — you get back to it through the logo. This gives it a name and - * a place in the list. The id matches the pattern Payload uses for its own items - * so it picks up an icon from the same stylesheet. - */ -export const DashboardNavLink: React.FC = () => { - const pathname = usePathname() - const isActive = pathname === '/admin' - - return ( -
- - Dashboard - -
- ) -} diff --git a/src/components/admin/KpiStrip/index.scss b/src/components/admin/KpiStrip/index.scss index 915d4b5..33b5935 100644 --- a/src/components/admin/KpiStrip/index.scss +++ b/src/components/admin/KpiStrip/index.scss @@ -1,78 +1,37 @@ -/** Headline figures. One bordered row, hairline dividers between metrics. */ .kpi-strip { display: grid; - grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); - border: 1px solid var(--theme-elevation-100); - border-radius: 10px; - background: var(--theme-elevation-0); - overflow: hidden; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.9rem; &__item { - display: flex; - flex-direction: column; - gap: 0.55rem; - padding: 1.3rem 1.5rem; - border-left: 1px solid var(--theme-elevation-100); - - &:first-child { border-left: 0; } - } - - &__label { - font-size: 1rem; - color: var(--theme-elevation-500); - display: flex; - align-items: center; - gap: 0.45rem; - } - - &__icon { flex: 0 0 auto; } - - /* Value and delta share a baseline; the comparison always sits on its own line - so a long figure in one column cannot push the row out of step with the rest. */ - &__figure { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 0.45rem; - min-height: 1.9rem; + display: grid; min-width: 0; min-height: 8.6rem; grid-template-columns: 2.75rem minmax(0, 1fr); + align-items: start; gap: 1rem; padding: 1.35rem 1.4rem; + border: 1px solid var(--admin-edge, var(--theme-elevation-100)); + border-radius: var(--admin-radius-panel, 12px); + background: var(--admin-panel, var(--theme-elevation-0)); } - + &__icon-wrap { display: grid; width: 2.75rem; height: 2.75rem; place-items: center; border-radius: 10px; color: var(--theme-elevation-850); background: var(--admin-panel-muted); } + &__icon { width: 1.45rem; height: 1.45rem; } + &__content { display: flex; min-width: 0; flex-direction: column; gap: 0.55rem; } + &__label { font-size: 0.95rem; } + &__figure { display: flex; align-items: baseline; flex-wrap: wrap; gap: 0.65rem; } &__value { - font-size: 2rem; - font-weight: 600; - line-height: 1.05; + color: var(--theme-elevation-950); font-size: clamp(1.8rem, 2vw, 2.25rem); + font-weight: 650; line-height: 1; letter-spacing: -0.025em; font-variant-numeric: tabular-nums; - letter-spacing: -0.01em; } - &__delta { - font-size: 0.95rem; - font-weight: 600; - font-variant-numeric: tabular-nums; - display: inline-flex; - align-items: center; - gap: 0.12rem; - - &--up { color: #16a34a; } - &--down { color: #dc2626; } + display: inline-flex; align-items: center; gap: 0.12rem; font-size: 0.9rem; + font-weight: 650; font-variant-numeric: tabular-nums; + &--up { color: #18833d; } + &--down { color: #c93c35; } &--flat { color: var(--theme-elevation-500); } } - - &__compare { - font-size: 0.925rem; - color: var(--theme-elevation-450); - margin-top: -0.15rem; - } + &__compare { color: var(--theme-elevation-450); font-size: 0.86rem; font-style: italic; } } - -@media (prefers-color-scheme: dark) { - :root:where(:not([data-theme='light'])) .kpi-strip { - &__delta--up { color: #4ade80; } - &__delta--down { color: #f87171; } - } -} - -:root[data-theme='dark'] .kpi-strip { - &__delta--up { color: #4ade80; } - &__delta--down { color: #f87171; } +@media (max-width: 76rem) { .kpi-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } } +@media (max-width: 38rem) { .kpi-strip { grid-template-columns: minmax(0, 1fr); } } +:root[data-theme='dark'] .kpi-strip__delta { + &--up { color: #59c777; } + &--down { color: #f17870; } } diff --git a/src/components/admin/KpiStrip/index.tsx b/src/components/admin/KpiStrip/index.tsx index 2981724..08f0f52 100644 --- a/src/components/admin/KpiStrip/index.tsx +++ b/src/components/admin/KpiStrip/index.tsx @@ -24,20 +24,22 @@ export const KpiStrip: React.FC<{ items: Kpi[] }> = ({ items }) => ( const dir = k.delta === undefined ? null : k.delta > 0 ? 'up' : k.delta < 0 ? 'down' : 'flat' return (
- +
{k.Icon && } - {k.label} - -
- {k.value} - {dir && ( - - - {Math.abs(k.delta as number).toFixed(k.delta === Math.round(k.delta as number) ? 0 : 1)}% - - )}
- {k.compare && {k.compare}} +
+ {k.label} +
+ {k.value} + {dir && ( + + + {Math.abs(k.delta as number).toFixed(k.delta === Math.round(k.delta as number) ? 0 : 1)}% + + )} +
+ {k.compare && {k.compare}} +
) })} diff --git a/src/components/admin/OrderHealth/index.scss b/src/components/admin/OrderHealth/index.scss index 2365e78..11e94e8 100644 --- a/src/components/admin/OrderHealth/index.scss +++ b/src/components/admin/OrderHealth/index.scss @@ -1,143 +1,33 @@ -/** - * Order pipeline health. - * - * Status keeps its conventional green / amber / red reading, stepped to the same - * brightness as the Popular products hues so the two panels sit together. Refunded - * stays neutral — it is neither good nor bad. Every segment ships with a written - * label and a count, and 2px of surface separates them, so the bar never carries - * meaning by colour alone; that secondary encoding is what licenses the amber and - * green sitting adjacent at a protan ΔE in the floor band. - */ - .order-health { - --ok: #16a34a; - --busy: #f59e0b; - --refunded: #6b7280; - --bad: #dc2626; - - border: 1px solid var(--theme-elevation-100); - border-radius: var(--style-radius-s, 3px); - padding: 1.15rem 1.25rem; - background: var(--theme-elevation-0); - - &__title { - margin: 0 0 0.85rem; - font-size: 1.08rem; - font-weight: 600; - display: flex; - align-items: center; - gap: 0.5rem; - } - - &__icon { color: var(--theme-elevation-500); flex: 0 0 auto; } - - &__note { - font-size: 0.925rem; - font-weight: 400; - color: var(--theme-elevation-450); - } - - /** - * No overflow clip here — the hover reading rises above the bar, and a clip - * would shave it off. Each segment carries its own radius instead. - */ - &__bar { - display: flex; - gap: 2px; - height: 24px; - } - - &__seg { - position: relative; - display: block; - height: 100%; - border-radius: 2px; - cursor: default; - - &--completed { background: var(--ok); } - &--processing { background: var(--busy); } - &--refunded { background: var(--refunded); } - &--cancelled { background: var(--bad); } - } - - /** - * The reading drops below the segment rather than above it: the bar sits close - * to the panel's top edge, and rising would push the box outside the card. - */ - &__tip { - position: absolute; - top: calc(100% + 8px); - left: 50%; - z-index: 2; - transform: translateX(-50%); - display: flex; - flex-direction: column; - gap: 0.1rem; - padding: 0.35rem 0.55rem; - border: 1px solid var(--theme-elevation-150); - border-radius: 6px; - background: var(--theme-elevation-0); - box-shadow: 0 2px 6px rgb(0 0 0 / 0.08); - pointer-events: none; - white-space: nowrap; - - /* A thin segment at either end would push the box past the card. */ - &--start { left: 0; transform: none; } - &--end { left: auto; right: 0; transform: none; } - } - - &__tip-label { - font-size: 0.85rem; - color: var(--theme-elevation-500); - } - - &__tip-value { - font-size: 1rem; - font-weight: 600; - font-variant-numeric: tabular-nums; - color: var(--theme-elevation-900); - } - - &__key { - display: flex; - flex-wrap: wrap; - gap: 0.35rem 1.25rem; - margin: 0.8rem 0 0; - padding: 0; - list-style: none; - font-size: 0.95rem; - color: var(--theme-elevation-650); - - li { display: flex; align-items: center; gap: 0.45rem; } - b { color: var(--theme-elevation-800); font-variant-numeric: tabular-nums; } - } - - &__swatch { - width: 0.6rem; - height: 0.6rem; - border-radius: 2px; - flex: 0 0 auto; - - &--completed { background: var(--ok); } - &--processing { background: var(--busy); } - &--refunded { background: var(--refunded); } - &--cancelled { background: var(--bad); } - } - + --ok: #2f7d25; --busy: #d89a18; --refunded: #b8b8ad; --bad: #d33d34; + border: 1px solid var(--admin-edge, var(--theme-elevation-100)); + border-radius: var(--admin-radius-panel, 12px); padding: 1.15rem 1.25rem; + background: var(--admin-panel, var(--theme-elevation-0)); + + &__title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.55rem; font-size: 1.08rem; font-weight: 650; } + &__icon { width: 1rem; height: 1rem; color: var(--theme-elevation-500); } + &__note { color: var(--theme-elevation-450); font-size: 0.84rem; font-weight: 400; } + &__visual { position: relative; display: grid; width: 9.25rem; height: 9.25rem; margin: 0.3rem auto 0.65rem; place-items: center; } + &__ring { width: 100%; height: 100%; transform: rotate(-90deg); overflow: visible; } + &__ring-track, &__ring-segment { fill: none; stroke-width: 12; } + &__ring-track { stroke: var(--admin-panel-muted); } + &__ring-segment { stroke-linecap: round; } + &__ring-segment--completed { stroke: var(--ok); } + &__ring-segment--processing { stroke: var(--busy); } + &__ring-segment--refunded { stroke: var(--refunded); } + &__ring-segment--cancelled { stroke: var(--bad); } + &__score { position: absolute; display: flex; flex-direction: column; align-items: center; line-height: 1.15; } + &__score strong { color: var(--theme-elevation-950); font-size: 1.65rem; font-variant-numeric: tabular-nums; } + &__score span { color: var(--theme-elevation-500); font-size: 0.83rem; } + &__key { display: flex; flex-direction: column; gap: 0; margin: 0; padding: 0; list-style: none; } + &__key li { display: grid; grid-template-columns: 0.55rem minmax(0, 1fr) auto auto; align-items: center; gap: 0.55rem; padding: 0.42rem 0; border-top: 1px solid var(--admin-edge); color: var(--theme-elevation-650); font-size: 0.88rem; } + &__key b, &__key em { font-variant-numeric: tabular-nums; font-style: normal; } + &__key b { color: var(--theme-elevation-800); } + &__key em { min-width: 2rem; color: var(--theme-elevation-450); text-align: right; } + &__swatch { width: 0.55rem; height: 0.55rem; border-radius: 50%; } + &__swatch--completed { background: var(--ok); } + &__swatch--processing { background: var(--busy); } + &__swatch--refunded { background: var(--refunded); } + &__swatch--cancelled { background: var(--bad); } &__empty { margin: 0; color: var(--theme-elevation-450); } } - -/* Amber and red are stepped down for the dark surface; the greens hold. */ -@media (prefers-color-scheme: dark) { - :root:where(:not([data-theme='light'])) .order-health { - --busy: #d97706; - --refunded: #9aa1ab; - --bad: #b91c1c; - } -} - -:root[data-theme='dark'] .order-health { - --busy: #d97706; - --refunded: #9aa1ab; - --bad: #b91c1c; -} diff --git a/src/components/admin/OrderHealth/index.tsx b/src/components/admin/OrderHealth/index.tsx index c373722..18ec72c 100644 --- a/src/components/admin/OrderHealth/index.tsx +++ b/src/components/admin/OrderHealth/index.tsx @@ -11,7 +11,7 @@ export type StatusCount = { completed: number; processing: number; cancelled: nu type Props = { counts: StatusCount } /** - * The state of the order pipeline, as one bar. + * The state of the order pipeline, as a compact ring. * * Status is a reserved role, not a series — each state gets its own fixed colour * and a written label, so it never reads by colour alone. Segments are ordered @@ -19,7 +19,6 @@ type Props = { counts: StatusCount } * Hovering a segment names it and adds its share, which the key below cannot show. */ export const OrderHealth: React.FC = ({ counts }) => { - const [hover, setHover] = React.useState(null) const total = counts.completed + counts.processing + counts.cancelled + counts.refunded const segments = [ @@ -40,40 +39,45 @@ export const OrderHealth: React.FC = ({ counts }) => {

Nothing to report until the first order lands.

) : ( <> -
`${s.n} ${s.label.toLowerCase()}`).join(', ')} - className="order-health__bar" - role="img" - > - {segments.map((s, i) => ( - setHover(s.key)} - onPointerLeave={() => setHover((h) => (h === s.key ? null : h))} - style={{ width: `${(s.n / total) * 100}%` }} - > - {hover === s.key && ( - - {s.label} - - {s.n} · {Math.round((s.n / total) * 100)}% - - - )} - - ))} +
+ `${s.n} ${s.label.toLowerCase()}`).join(', ')} + className="order-health__ring" + role="img" + viewBox="0 0 120 120" + > + + {segments.reduce((nodes, segment, index) => { + const previous = segments.slice(0, index).reduce((sum, item) => sum + item.n, 0) + const length = (segment.n / total) * 100 + nodes.push( + , + ) + return nodes + }, [])} + + + {Math.round((counts.completed / total) * 100)}% + Healthy +
    {segments.map((s) => (
  • - {s.label} {s.n} + {s.label} + {Math.round((s.n / total) * 100)}% + {s.n}
  • ))}
diff --git a/src/components/admin/PopularProducts/index.scss b/src/components/admin/PopularProducts/index.scss index 7e2892b..7bb107d 100644 --- a/src/components/admin/PopularProducts/index.scss +++ b/src/components/admin/PopularProducts/index.scss @@ -1,105 +1,21 @@ -/** - * Best sellers. - * - * Hues are the validated bright set — orange, violet, green, sky, pink — which - * clears colour-blind separation in both themes. Only the orange changes between - * light and dark; the rest hold their step. - * - * The hatched strip under each bar is the full scale the bar is measured against, - * so a short bar still reads as a share of the same whole. - */ - .popular { - --c1: #f97316; - --c2: #7c3aed; - --c3: #16a34a; - --c4: #0284c7; - --c5: #db2777; - - border: 1px solid var(--theme-elevation-100); - border-radius: 10px; - padding: 1.4rem 1.5rem 1.25rem; - background: var(--theme-elevation-0); - - &__title { - margin: 0 0 1.15rem; - font-size: 1.08rem; - font-weight: 600; - display: flex; - align-items: center; - gap: 0.5rem; - } - - &__icon { color: var(--theme-elevation-500); flex: 0 0 auto; } - - &__list { - margin: 0; - padding: 0; - list-style: none; - display: flex; - flex-direction: column; - gap: 1.05rem; - } - - &__label { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 1rem; - margin-bottom: 0.4rem; - } - - &__name { font-size: 1rem; min-width: 0; overflow-wrap: anywhere; } - - &__units { - font-size: 0.925rem; - color: var(--theme-elevation-500); - font-variant-numeric: tabular-nums; - white-space: nowrap; + border: 1px solid var(--admin-edge, var(--theme-elevation-100)); + border-radius: var(--admin-radius-panel, 12px); padding: 1.25rem 1.35rem; + background: var(--admin-panel, var(--theme-elevation-0)); + + &__title { display: flex; align-items: center; gap: 0.55rem; margin: 0 0 0.85rem; font-size: 1.08rem; font-weight: 650; } + &__icon { width: 1rem; height: 1rem; color: var(--theme-elevation-500); } + &__list { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; } + &__row { + display: grid; grid-template-columns: 1rem 2.65rem minmax(0, 1fr) auto auto; + align-items: center; gap: 0.75rem; min-height: 3.55rem; border-bottom: 1px solid var(--admin-edge); + &:last-child { border-bottom: 0; } } - - /* Hatched remainder: the rest of the scale, shown rather than implied. */ - &__track { - position: relative; - height: 9px; - border-radius: 5px; - background-image: repeating-linear-gradient( - -45deg, - var(--theme-elevation-100) 0 3px, - transparent 3px 6px - ); - background-color: var(--theme-elevation-50); - overflow: hidden; - } - - &__bar { - height: 100%; - border-radius: 5px; - background: var(--theme-elevation-400); - } - - &__row--1 .popular__bar { background: var(--c1); } - &__row--2 .popular__bar { background: var(--c2); } - &__row--3 .popular__bar { background: var(--c3); } - &__row--4 .popular__bar { background: var(--c4); } - &__row--5 .popular__bar { background: var(--c5); } - - &__axis { - display: flex; - justify-content: space-between; - margin-top: 0.9rem; - padding-top: 0.6rem; - border-top: 1px solid var(--theme-elevation-100); - font-size: 0.85rem; - color: var(--theme-elevation-450); - font-variant-numeric: tabular-nums; - } - + &__rank { color: var(--theme-elevation-450); font-size: 0.9rem; text-align: center; } + &__image { width: 2.65rem; height: 2.65rem; object-fit: cover; border-radius: 8px; background: var(--admin-panel-muted); } + &__image--empty { display: block; } + &__name { min-width: 0; overflow: hidden; color: var(--theme-elevation-900); font-size: 0.95rem; font-weight: 550; text-overflow: ellipsis; white-space: nowrap; } + &__units { color: var(--theme-elevation-500); font-size: 0.86rem; font-variant-numeric: tabular-nums; white-space: nowrap; } + &__price { min-width: 4.5rem; color: var(--theme-elevation-850); font-size: 0.93rem; font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; } &__empty { margin: 0; color: var(--theme-elevation-450); } } - -@media (prefers-color-scheme: dark) { - :root:where(:not([data-theme='light'])) .popular { --c1: #ea580c; } -} - -:root[data-theme='dark'] .popular { --c1: #ea580c; } diff --git a/src/components/admin/PopularProducts/index.tsx b/src/components/admin/PopularProducts/index.tsx index d8f599f..7b724fa 100644 --- a/src/components/admin/PopularProducts/index.tsx +++ b/src/components/admin/PopularProducts/index.tsx @@ -1,10 +1,17 @@ import React from 'react' +import Image from 'next/image' import { IconPopular } from '../icons' import './index.scss' -export type PopularProduct = { title: string; units: number } +export type PopularProduct = { + alt?: string + image?: string + price?: string + title: string + units: number +} /** * Best sellers by units. @@ -14,12 +21,6 @@ export type PopularProduct = { title: string; units: number } * decoration on an already-labelled row, never the thing carrying the value. */ export const PopularProducts: React.FC<{ data: PopularProduct[] }> = ({ data }) => { - const peak = Math.max(1, ...data.map((d) => d.units)) - // A round axis top so the scale ticks name whole units. - const step = peak <= 50 ? 25 : peak <= 200 ? 50 : 100 - const top = Math.ceil(peak / step) * step - const ticks = Array.from({ length: top / step + 1 }, (_, i) => i * step) - return (

@@ -30,27 +31,27 @@ export const PopularProducts: React.FC<{ data: PopularProduct[] }> = ({ data }) {data.length === 0 ? (

Best sellers appear here once orders start coming in.

) : ( - <> -
    - {data.map((d, i) => ( -
  1. -
    - {d.title} - {d.units.toLocaleString('en-US')} sold -
    -
    -
    -
    -
  2. - ))} -
- - - +
    + {data.map((d, i) => ( +
  1. + {i + 1} + {d.image ? ( + {d.alt + ) : ( +
  2. + ))} +
)}

) diff --git a/src/components/admin/ProductInventoryHint/index.tsx b/src/components/admin/ProductInventoryHint/index.tsx new file mode 100644 index 0000000..d253e99 --- /dev/null +++ b/src/components/admin/ProductInventoryHint/index.tsx @@ -0,0 +1,15 @@ +'use client' + +import { useFormFields } from '@payloadcms/ui' +import React from 'react' + +export const ProductInventoryHint: React.FC = () => { + const variantsEnabled = useFormFields(([fields]) => Boolean(fields.enableVariants?.value)) + return ( +

+ {variantsEnabled + ? 'Inventory is tracked on each variant in the table.' + : 'Set the number of units currently available.'} +

+ ) +} diff --git a/src/components/admin/ProductPublishButton/index.tsx b/src/components/admin/ProductPublishButton/index.tsx new file mode 100644 index 0000000..48770b7 --- /dev/null +++ b/src/components/admin/ProductPublishButton/index.tsx @@ -0,0 +1,9 @@ +'use client' + +import { PublishButton } from '@payloadcms/ui' +import type { PublishButtonClientProps } from 'payload' +import React from 'react' + +export const ProductPublishButton: React.FC = (props) => ( + +) diff --git a/src/components/admin/ProductPublishing/index.scss b/src/components/admin/ProductPublishing/index.scss new file mode 100644 index 0000000..ddfab79 --- /dev/null +++ b/src/components/admin/ProductPublishing/index.scss @@ -0,0 +1,15 @@ +.product-publishing { + padding: 1.15rem; + border: 1px solid var(--admin-edge); + border-radius: var(--admin-radius-panel); + background: var(--admin-panel); + + h3 { margin: 0 0 0.85rem; font-size: 1.08rem; font-weight: 650; } + dl { display: flex; flex-direction: column; gap: 0.7rem; margin: 0; } + dl > div { display: grid; grid-template-columns: 6.5rem minmax(0, 1fr); align-items: center; gap: 0.75rem; } + dt { color: var(--theme-elevation-650); } + dd { display: flex; align-items: center; gap: 0.45rem; min-width: 0; margin: 0; color: var(--theme-elevation-850); font-variant-numeric: tabular-nums; } + .is-live, .is-draft { width: 0.55rem; height: 0.55rem; flex: 0 0 auto; border-radius: 50%; } + .is-live { background: #4f9a3b; } + .is-draft { background: #c89420; } +} diff --git a/src/components/admin/ProductPublishing/index.tsx b/src/components/admin/ProductPublishing/index.tsx new file mode 100644 index 0000000..cfb9520 --- /dev/null +++ b/src/components/admin/ProductPublishing/index.tsx @@ -0,0 +1,49 @@ +'use client' + +import { useDocumentInfo, useFormFields } from '@payloadcms/ui' +import React from 'react' + +import './index.scss' + +const readableDate = (value: unknown) => { + if (typeof value !== 'string') return 'Not published yet' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return 'Not published yet' + return date.toLocaleString('en-US', { + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + month: 'short', + year: 'numeric', + }) +} + +export const ProductPublishing: React.FC = () => { + const status = useFormFields(([fields]) => fields._status?.value) + const { data } = useDocumentInfo() + const published = status === 'published' + + return ( +
+

Publishing

+
+
+
Status
+
{published ? 'Published' : 'Draft'}
+
+
+
Visibility
+
{published ? 'Visible' : 'Hidden'}
+
+
+
Published at
+
{readableDate(data?.createdAt)}
+
+
+
Updated at
+
{readableDate(data?.updatedAt)}
+
+
+
+ ) +} diff --git a/src/components/admin/RecentOrders/index.scss b/src/components/admin/RecentOrders/index.scss index c8a9ea9..5683b8d 100644 --- a/src/components/admin/RecentOrders/index.scss +++ b/src/components/admin/RecentOrders/index.scss @@ -1,8 +1,8 @@ .recent-orders { padding: 1.15rem 1.25rem; - border: 1px solid var(--theme-elevation-100); - border-radius: var(--style-radius-s, 3px); - background: var(--theme-elevation-0); + border: 1px solid var(--admin-edge, var(--theme-elevation-100)); + border-radius: var(--admin-radius-panel, 12px); + background: var(--admin-panel, var(--theme-elevation-0)); &__head { display: flex; @@ -43,8 +43,8 @@ &__labels, &__row { display: grid; - grid-template-columns: 0.65fr minmax(8rem, 1.7fr) 0.9fr 1fr 1fr 0.8fr; - gap: 1rem; + grid-template-columns: 0.62fr minmax(6.5rem, 1.45fr) 0.8fr 0.9fr 0.9fr 0.65fr; + gap: 0.7rem; align-items: center; } @@ -56,20 +56,16 @@ &__row { min-width: 0; - padding: 0.68rem 0.7rem; - border-radius: var(--style-radius-s, 3px); + padding: 0.62rem 0; + border-bottom: 1px solid var(--admin-edge); color: var(--theme-elevation-700); font-size: 1rem; text-decoration: none; transition: background-color 120ms ease; - &:nth-child(even) { - background: var(--theme-elevation-50); - } + &:last-child { border-bottom: 0; } - &:hover { - background: var(--theme-elevation-100); - } + &:hover { color: var(--theme-elevation-900); background: var(--admin-panel-muted); } &:focus-visible { outline: 2px solid var(--theme-elevation-700); @@ -97,10 +93,16 @@ } &__status { + justify-self: start; + padding: 0.2rem 0.48rem; + border-radius: 999px; + background: var(--admin-panel-muted); color: var(--theme-elevation-650); + font-size: 0.82rem; &--succeeded { color: #16803b; + background: #e1f3e6; } &--failed { @@ -110,14 +112,17 @@ &--pending, &--processing { color: var(--theme-warning-600, #9a6700); + background: #fff1c8; } &--completed { color: #16803b; + background: #e1f3e6; } &--cancelled { color: #b42318; + background: #fbe4e1; } &--refunded { @@ -209,4 +214,13 @@ &--cancelled { color: #f87171; } + + &--succeeded, + &--completed { background: rgb(34 197 94 / 0.14); } + + &--pending, + &--processing { background: rgb(245 158 11 / 0.14); } + + &--cancelled, + &--failed { background: rgb(239 68 68 / 0.14); } } diff --git a/src/components/admin/RevenueTrend/index.scss b/src/components/admin/RevenueTrend/index.scss index 6859cef..35573fe 100644 --- a/src/components/admin/RevenueTrend/index.scss +++ b/src/components/admin/RevenueTrend/index.scss @@ -6,13 +6,13 @@ */ .revenue-trend { - --current: #7c3aed; + --current: #315f25; margin: 0; - border: 1px solid var(--theme-elevation-100); - border-radius: 10px; - padding: 1.4rem 1.5rem 1rem; - background: var(--theme-elevation-0); + border: 1px solid var(--admin-edge, var(--theme-elevation-100)); + border-radius: var(--admin-radius-panel, 10px); + padding: 1.25rem 1.35rem 0.85rem; + background: var(--admin-panel, var(--theme-elevation-0)); &__head { display: flex; @@ -68,7 +68,7 @@ margin: 1rem 0 0.55rem; padding: 0.7rem 0; border-radius: var(--style-radius-s, 3px); - background: var(--theme-elevation-50); + background: var(--admin-panel-muted, var(--theme-elevation-50)); dt { margin: 0 0 0.25rem; @@ -140,6 +140,10 @@ transition: opacity 0.12s ease; } + &__bar--context { + fill: color-mix(in srgb, var(--current) 14%, transparent); + } + /* Reading a day dims the rest of the month so the read-out bar is unmistakable. */ &__plot--reading &__bar { opacity: 0.4; } &__plot--reading &__bar--active { opacity: 1; } diff --git a/src/components/admin/RevenueTrend/index.tsx b/src/components/admin/RevenueTrend/index.tsx index fee1dc7..5f72833 100644 --- a/src/components/admin/RevenueTrend/index.tsx +++ b/src/components/admin/RevenueTrend/index.tsx @@ -90,7 +90,7 @@ const exactMoney = (minor: number, currency: string) => * and the choice is remembered. */ type Mode = 'line' | 'bars' -const STORAGE_KEY = 'marisol-admin:revenue-chart-mode:v2' +const STORAGE_KEY = 'marisol-admin:revenue-chart-mode:v3' export const RevenueTrend: React.FC = ({ breakdown, @@ -104,7 +104,7 @@ export const RevenueTrend: React.FC = ({ comparisonLabel = 'vs previous period', title = 'Total revenue', }) => { - const [mode, setMode] = React.useState('bars') + const [mode, setMode] = React.useState('line') const [hover, setHover] = React.useState(null) const svgRef = React.useRef(null) @@ -268,6 +268,20 @@ export const RevenueTrend: React.FC = ({ {mode === 'line' ? ( <> + {current.points.map((v, i) => { + const w = Math.min(34, Math.max(0.8, (plotW / n) * 0.54)) + return ( + 0 ? 1.5 : 0, PAD.top + plotH - y(v))} + key={i} + rx={Math.min(2, w / 2)} + width={w} + x={x(i) - w / 2} + y={y(v)} + /> + ) + })} {lastIdx >= 0 && ( diff --git a/src/components/admin/icons/index.tsx b/src/components/admin/icons/index.tsx index 7af21e5..3bf92d3 100644 --- a/src/components/admin/icons/index.tsx +++ b/src/components/admin/icons/index.tsx @@ -63,6 +63,41 @@ export const IconProducts: React.FC = (p) => ( ) +/** Customers — two people, kept open and legible at dashboard scale. */ +export const IconCustomers: React.FC = (p) => ( + + + + + +) + +/** Fulfilment — a parcel ready to leave the store. */ +export const IconFulfilment: React.FC = (p) => ( + + + + +) + +/** Payment warning — a card with an interrupted payment mark. */ +export const IconPaymentWarning: React.FC = (p) => ( + + + + +) + +/** Incomplete checkout — a cart waiting for action. */ +export const IconCheckout: React.FC = (p) => ( + + + + + + +) + /** Trend — a rising line. */ export const IconTrend: React.FC = (p) => ( diff --git a/src/payload-types.ts b/src/payload-types.ts index 94c9541..a576258 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -301,6 +301,11 @@ export interface Order { export interface Product { id: number; title: string; + /** + * When enabled, the slug will auto-generate from the title field on save and autosave. + */ + generateSlug?: boolean | null; + slug: string; description?: { root: { type: string; @@ -323,8 +328,6 @@ export interface Product { id?: string | null; }[] | null; - layout?: (CallToActionBlock | ContentBlock | MediaBlock)[] | null; - inventory?: number | null; enableVariants?: boolean | null; variantTypes?: (number | VariantType)[] | null; variants?: { @@ -332,12 +335,7 @@ export interface Product { hasNextPage?: boolean; totalDocs?: number; }; - priceInUSDEnabled?: boolean | null; - priceInUSD?: number | null; - priceInEUREnabled?: boolean | null; - priceInEUR?: number | null; - priceInGBPEnabled?: boolean | null; - priceInGBP?: number | null; + layout?: (CallToActionBlock | ContentBlock | MediaBlock)[] | null; relatedProducts?: (number | Product)[] | null; meta?: { title?: string | null; @@ -349,11 +347,13 @@ export interface Product { }; categories?: (number | Category)[] | null; tags?: (number | Tag)[] | null; - /** - * When enabled, the slug will auto-generate from the title field on save and autosave. - */ - generateSlug?: boolean | null; - slug: string; + inventory?: number | null; + priceInUSDEnabled?: boolean | null; + priceInUSD?: number | null; + priceInEUREnabled?: boolean | null; + priceInEUR?: number | null; + priceInGBPEnabled?: boolean | null; + priceInGBP?: number | null; updatedAt: string; createdAt: string; deletedAt?: string | null; @@ -463,6 +463,30 @@ export interface VariantType { createdAt: string; deletedAt?: string | null; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "variants". + */ +export interface Variant { + id: number; + /** + * Used for administrative purposes, not shown to customers. This is populated by default. + */ + title?: string | null; + product: number | Product; + options: (number | VariantOption)[]; + inventory?: number | null; + priceInUSDEnabled?: boolean | null; + priceInUSD?: number | null; + priceInEUREnabled?: boolean | null; + priceInEUR?: number | null; + priceInGBPEnabled?: boolean | null; + priceInGBP?: number | null; + updatedAt: string; + createdAt: string; + deletedAt?: string | null; + _status?: ('draft' | 'published') | null; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "CallToActionBlock". @@ -941,30 +965,6 @@ export interface Form { updatedAt: string; createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "variants". - */ -export interface Variant { - id: number; - /** - * Used for administrative purposes, not shown to customers. This is populated by default. - */ - title?: string | null; - product: number | Product; - options: (number | VariantOption)[]; - inventory?: number | null; - priceInUSDEnabled?: boolean | null; - priceInUSD?: number | null; - priceInEUREnabled?: boolean | null; - priceInEUR?: number | null; - priceInGBPEnabled?: boolean | null; - priceInGBP?: number | null; - updatedAt: string; - createdAt: string; - deletedAt?: string | null; - _status?: ('draft' | 'published') | null; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "tags". @@ -1761,6 +1761,8 @@ export interface VariantOptionsSelect { */ export interface ProductsSelect { title?: T; + generateSlug?: T; + slug?: T; description?: T; gallery?: | T @@ -1769,6 +1771,9 @@ export interface ProductsSelect { variantOption?: T; id?: T; }; + enableVariants?: T; + variantTypes?: T; + variants?: T; layout?: | T | { @@ -1776,16 +1781,6 @@ export interface ProductsSelect { content?: T | ContentBlockSelect; mediaBlock?: T | MediaBlockSelect; }; - inventory?: T; - enableVariants?: T; - variantTypes?: T; - variants?: T; - priceInUSDEnabled?: T; - priceInUSD?: T; - priceInEUREnabled?: T; - priceInEUR?: T; - priceInGBPEnabled?: T; - priceInGBP?: T; relatedProducts?: T; meta?: | T @@ -1796,8 +1791,13 @@ export interface ProductsSelect { }; categories?: T; tags?: T; - generateSlug?: T; - slug?: T; + inventory?: T; + priceInUSDEnabled?: T; + priceInUSD?: T; + priceInEUREnabled?: T; + priceInEUR?: T; + priceInGBPEnabled?: T; + priceInGBP?: T; updatedAt?: T; createdAt?: T; deletedAt?: T; diff --git a/src/payload.config.ts b/src/payload.config.ts index 3cd8d3b..a0f4aee 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -37,9 +37,9 @@ export default buildConfig({ // The `BeforeLogin` component renders a message that you see while logging into your admin panel. // Feel free to delete this at any time. Simply remove the line below and the import `BeforeLogin` statement on line 15. beforeLogin: ['@/components/BeforeLogin#BeforeLogin'], - // Payload's nav is built from collections and globals, so the admin home - // has no entry of its own. This adds one. - beforeNavLinks: ['@/components/admin/DashboardNavLink#DashboardNavLink'], + // A commerce-focused navigation replaces Payload's collection-group list + // while preserving the same routes, permissions, and editing screens. + Nav: '@/components/admin/AdminNav#AdminNav', views: { // Replaces the default home, which listed every collection as a card grid // that duplicates the sidebar. Seeding moved to the CLI: `pnpm seed`.