| 1 |
import React from "react"; |
| 2 |
import { __ } from "@wordpress/i18n"; |
| 3 |
import { I } from "./icons"; |
| 4 |
import { Sparkline } from "./charts"; |
| 5 |
|
| 6 |
export function cn( ...c ) { return c.filter( Boolean ).join( " " ); } |
| 7 |
|
| 8 |
export const fmt = ( n ) => ( Number( n ) || 0 ).toLocaleString(); |
| 9 |
export const fmtCompact = ( n ) => { |
| 10 |
n = Number( n ) || 0; |
| 11 |
if ( n < 1000 ) return String( n ); |
| 12 |
for ( const u of [ { v: 1e9, s: "B" }, { v: 1e6, s: "M" }, { v: 1e3, s: "K" } ] ) { |
| 13 |
if ( n >= u.v ) return ( n / u.v ).toFixed( n % u.v === 0 ? 0 : 1 ).replace( /\.0$/, "" ) + u.s; |
| 14 |
} |
| 15 |
return String( n ); |
| 16 |
}; |
| 17 |
|
| 18 |
export function Btn( { kind = "ghost", size = "md", icon, children, className, ...rest } ) { |
| 19 |
return ( |
| 20 |
<button className={ cn( "bda-btn", kind, size === "sm" && "sm", className ) } { ...rest }> |
| 21 |
{ icon }{ children } |
| 22 |
</button> |
| 23 |
); |
| 24 |
} |
| 25 |
|
| 26 |
export function Badge( { children, color = "slate", className } ) { |
| 27 |
return <span className={ cn( "bda-badge", color, className ) }>{ children }</span>; |
| 28 |
} |
| 29 |
|
| 30 |
/* Design-system "Pro" badge — same markup quickbuilder renders in Settings |
| 31 |
(crown glyph from the betterdocs `btd-icon` font + amber tokens). */ |
| 32 |
export function ProBadge( { className } ) { |
| 33 |
return ( |
| 34 |
<span className={ cn( "wprf-badge", className ) }> |
| 35 |
<span className="wprf-badge-icon"><i className="btd-icon btd-crown" /></span> |
| 36 |
<span className="wprf-badge-item">{ __( "Pro", "betterdocs" ) }</span> |
| 37 |
</span> |
| 38 |
); |
| 39 |
} |
| 40 |
|
| 41 |
export function Card( { children, className, title, action, padded = true } ) { |
| 42 |
return ( |
| 43 |
<div className={ cn( "bda-card", className ) }> |
| 44 |
{ ( title || action ) && ( |
| 45 |
<div className="bda-card-head"> |
| 46 |
{ typeof title === "string" ? <h3 className="bda-card-title">{ title }</h3> : title } |
| 47 |
{ action } |
| 48 |
</div> |
| 49 |
) } |
| 50 |
<div className={ padded ? "bda-card-body" : "bda-card-body flush" }>{ children }</div> |
| 51 |
</div> |
| 52 |
); |
| 53 |
} |
| 54 |
|
| 55 |
export function Avatar( { author, size = 24 } ) { |
| 56 |
if ( ! author ) return null; |
| 57 |
return ( |
| 58 |
<span className="bda-avatar" style={ { width: size, height: size, fontSize: Math.max( 9, size * 0.42 ), background: `hsl(${ author.hue }, 62%, 50%)` } }> |
| 59 |
{ author.initials } |
| 60 |
</span> |
| 61 |
); |
| 62 |
} |
| 63 |
|
| 64 |
export function Trend( { value, suffix = "%" } ) { |
| 65 |
if ( value == null ) return null; |
| 66 |
const up = value >= 0; |
| 67 |
return ( |
| 68 |
<span className={ cn( "bda-trend", up ? "up" : "down" ) }> |
| 69 |
{ up ? <I.ArrowUp size={ 11 } strokeWidth={ 2.5 } /> : <I.ArrowDown size={ 11 } strokeWidth={ 2.5 } /> } |
| 70 |
{ Math.abs( value ).toFixed( 1 ) }{ suffix } |
| 71 |
</span> |
| 72 |
); |
| 73 |
} |
| 74 |
|
| 75 |
export function HelpBar( { score } ) { |
| 76 |
const color = score >= 80 ? "#00b884" : score >= 60 ? "#F59E0B" : "#EF4444"; |
| 77 |
return ( |
| 78 |
<div className="bda-helpbar"> |
| 79 |
<div className="bda-helpbar-track"><div className="bda-helpbar-fill" style={ { width: `${ score }%`, background: color } } /></div> |
| 80 |
<div className="bda-helpbar-val">{ score }%</div> |
| 81 |
</div> |
| 82 |
); |
| 83 |
} |
| 84 |
|
| 85 |
export function KpiCard( { label, value, spark, color = "#00b884", icon, delta, dir } ) { |
| 86 |
const up = dir > 0; |
| 87 |
return ( |
| 88 |
<div className="bda-kpi"> |
| 89 |
<div className="bda-kpi-top"> |
| 90 |
<div className="bda-kpi-label">{ label }</div> |
| 91 |
{ icon && <div className="bda-kpi-ico" style={ { background: color + "1A", color } }>{ icon }</div> } |
| 92 |
</div> |
| 93 |
<div className="bda-kpi-row"> |
| 94 |
<div> |
| 95 |
<div className="bda-kpi-value">{ value }</div> |
| 96 |
{ delta != null && ( |
| 97 |
<div className={ cn( "bda-trend", up ? "up" : "down" ) } style={ { marginTop: 8 } }> |
| 98 |
{ up ? <I.ArrowUp size={ 12 } strokeWidth={ 2.5 } /> : <I.ArrowDown size={ 12 } strokeWidth={ 2.5 } /> } |
| 99 |
<span>{ Math.abs( delta ).toFixed( 1 ) }%</span> |
| 100 |
<span style={ { color: "var(--text-color-500)", fontWeight: 400, marginLeft: 4 } }>vs prev</span> |
| 101 |
</div> |
| 102 |
) } |
| 103 |
</div> |
| 104 |
{ spark && <Sparkline points={ spark } color={ color } width={ 92 } height={ 36 } /> } |
| 105 |
</div> |
| 106 |
</div> |
| 107 |
); |
| 108 |
} |
| 109 |
|
| 110 |
export function Dropdown( { label, value, options, onChange, icon, align = "left", minWidth = 180 } ) { |
| 111 |
const [ open, setOpen ] = React.useState( false ); |
| 112 |
const ref = React.useRef( null ); |
| 113 |
React.useEffect( () => { |
| 114 |
const h = ( e ) => { if ( ref.current && ! ref.current.contains( e.target ) ) setOpen( false ); }; |
| 115 |
document.addEventListener( "mousedown", h ); |
| 116 |
return () => document.removeEventListener( "mousedown", h ); |
| 117 |
}, [] ); |
| 118 |
const selected = options.find( ( o ) => o.value === value ) || options[ 0 ]; |
| 119 |
return ( |
| 120 |
<div ref={ ref } className="bda-dd"> |
| 121 |
<button className="bda-dd-btn" onClick={ () => setOpen( ( o ) => ! o ) }> |
| 122 |
{ icon } |
| 123 |
{ label && <span className="lbl">{ label }:</span> } |
| 124 |
<span className="val">{ selected?.label }</span> |
| 125 |
<I.ChevronDown size={ 14 } style={ { color: "var(--text-color-500)" } } /> |
| 126 |
</button> |
| 127 |
{ open && ( |
| 128 |
<div className={ cn( "bda-dd-menu", align === "right" && "right" ) } style={ { minWidth } }> |
| 129 |
{ options.map( ( o ) => ( |
| 130 |
<button key={ o.value } className={ cn( "bda-dd-opt", o.value === value && "sel" ) } |
| 131 |
onClick={ () => { onChange( o.value ); setOpen( false ); } }> |
| 132 |
<span>{ o.label }</span> |
| 133 |
{ o.value === value && <I.Check size={ 14 } style={ { color: "#00b884" } } /> } |
| 134 |
</button> |
| 135 |
) ) } |
| 136 |
</div> |
| 137 |
) } |
| 138 |
</div> |
| 139 |
); |
| 140 |
} |
| 141 |
|
| 142 |
const DATE_PRESETS = [ |
| 143 |
{ value: "7", label: __( "Last 7 days", "betterdocs" ) }, |
| 144 |
{ value: "14", label: __( "Last 14 days", "betterdocs" ) }, |
| 145 |
{ value: "30", label: __( "Last 30 days", "betterdocs" ) } |
| 146 |
]; |
| 147 |
export function DateRange( { value, onChange } ) { |
| 148 |
return <Dropdown icon={ <I.Calendar size={ 14 } style={ { color: "var(--text-color-600)" } } /> } value={ String( value ) } options={ DATE_PRESETS } onChange={ ( v ) => onChange( parseInt( v, 10 ) ) } minWidth={ 170 } />; |
| 149 |
} |
| 150 |
|
| 151 |
export function Pagination( { page, totalPages, onPage, perPage = 20, total } ) { |
| 152 |
if ( totalPages <= 1 ) return ( |
| 153 |
<div className="bda-pag"><div className="bda-pag-info">{ total } { __( "items", "betterdocs" ) }</div></div> |
| 154 |
); |
| 155 |
const pages = []; |
| 156 |
for ( let i = 1; i <= totalPages; i++ ) { |
| 157 |
if ( i === 1 || i === totalPages || Math.abs( i - page ) <= 1 ) pages.push( i ); |
| 158 |
else if ( pages[ pages.length - 1 ] !== "…" ) pages.push( "…" ); |
| 159 |
} |
| 160 |
return ( |
| 161 |
<div className="bda-pag"> |
| 162 |
<div className="bda-pag-info"> |
| 163 |
{ __( "Showing", "betterdocs" ) } <b>{ ( page - 1 ) * perPage + 1 }–{ Math.min( page * perPage, total ) }</b> { __( "of", "betterdocs" ) } <b>{ total }</b> |
| 164 |
</div> |
| 165 |
<div className="bda-pag-btns"> |
| 166 |
<button disabled={ page <= 1 } onClick={ () => onPage( page - 1 ) }><I.ChevronLeft size={ 16 } /></button> |
| 167 |
{ pages.map( ( p, i ) => p === "…" |
| 168 |
? <span key={ i } style={ { width: 32, textAlign: "center", color: "var(--text-color-500)" } }>…</span> |
| 169 |
: <button key={ i } className={ p === page ? "cur" : "" } onClick={ () => onPage( p ) }>{ p }</button> ) } |
| 170 |
<button disabled={ page >= totalPages } onClick={ () => onPage( page + 1 ) }><I.ChevronRight size={ 16 } /></button> |
| 171 |
</div> |
| 172 |
</div> |
| 173 |
); |
| 174 |
} |
| 175 |
|
| 176 |
/* ── Empty / error / loading states (Claude design "States gallery") ── |
| 177 |
illustration: "data" | "search" | "error" */ |
| 178 |
export function EmptyArt( { kind = "data" } ) { |
| 179 |
if ( kind === "search" ) { |
| 180 |
return ( |
| 181 |
<svg width="120" height="100" viewBox="0 0 120 100" fill="none" aria-hidden="true"> |
| 182 |
<ellipse cx="60" cy="92" rx="44" ry="4" fill="#EDF0F3" /> |
| 183 |
<rect x="22" y="20" width="60" height="58" rx="6" fill="#fff" stroke="#d1fadf" strokeWidth="1.5" /> |
| 184 |
<rect x="30" y="32" width="44" height="2.5" rx="1.25" fill="#d1fadf" /> |
| 185 |
<rect x="30" y="40" width="32" height="2.5" rx="1.25" fill="#EDF0F3" /> |
| 186 |
<rect x="30" y="48" width="36" height="2.5" rx="1.25" fill="#EDF0F3" /> |
| 187 |
<circle cx="84" cy="62" r="14" fill="#fff" stroke="#00b884" strokeWidth="2.5" /> |
| 188 |
<line x1="94" y1="72" x2="104" y2="82" stroke="#00b884" strokeWidth="3" strokeLinecap="round" /> |
| 189 |
</svg> |
| 190 |
); |
| 191 |
} |
| 192 |
if ( kind === "error" ) { |
| 193 |
return ( |
| 194 |
<svg width="120" height="100" viewBox="0 0 120 100" fill="none" aria-hidden="true"> |
| 195 |
<ellipse cx="60" cy="92" rx="44" ry="4" fill="#FEE2E2" /> |
| 196 |
<path d="M60 20 L96 80 H24 Z" fill="#FEF2F2" stroke="#EF4444" strokeWidth="2" strokeLinejoin="round" /> |
| 197 |
<line x1="60" y1="40" x2="60" y2="60" stroke="#EF4444" strokeWidth="3" strokeLinecap="round" /> |
| 198 |
<circle cx="60" cy="70" r="2.5" fill="#EF4444" /> |
| 199 |
</svg> |
| 200 |
); |
| 201 |
} |
| 202 |
return ( |
| 203 |
<svg width="120" height="100" viewBox="0 0 120 100" fill="none" aria-hidden="true"> |
| 204 |
<ellipse cx="60" cy="92" rx="44" ry="4" fill="#EDF0F3" /> |
| 205 |
<rect x="22" y="22" width="76" height="58" rx="8" fill="#fff" stroke="#d1fadf" strokeWidth="1.5" /> |
| 206 |
<path d="M30 66 L42 54 L52 62 L66 46 L78 56 L90 44" stroke="#00b884" strokeWidth="2.2" fill="none" strokeLinecap="round" strokeLinejoin="round" /> |
| 207 |
<circle cx="42" cy="54" r="2.5" fill="#00b884" /><circle cx="52" cy="62" r="2.5" fill="#00b884" /> |
| 208 |
<circle cx="66" cy="46" r="2.5" fill="#00b884" /><circle cx="78" cy="56" r="2.5" fill="#00b884" /><circle cx="90" cy="44" r="2.5" fill="#00b884" /> |
| 209 |
<rect x="30" y="30" width="22" height="3" rx="1.5" fill="#d1fadf" /> |
| 210 |
<rect x="30" y="36" width="14" height="2.5" rx="1.25" fill="#EDF0F3" /> |
| 211 |
</svg> |
| 212 |
); |
| 213 |
} |
| 214 |
|
| 215 |
export function EmptyState( { illustration = "data", title, subtitle, actionLabel, onAction } ) { |
| 216 |
return ( |
| 217 |
<div className="bda-empty"> |
| 218 |
<EmptyArt kind={ illustration } /> |
| 219 |
<h3>{ title }</h3> |
| 220 |
{ subtitle && <p>{ subtitle }</p> } |
| 221 |
{ actionLabel && ( |
| 222 |
<button className="bda-empty-action" onClick={ onAction }> |
| 223 |
{ actionLabel } <I.ArrowRight size={ 13 } /> |
| 224 |
</button> |
| 225 |
) } |
| 226 |
</div> |
| 227 |
); |
| 228 |
} |
| 229 |
|
| 230 |
export function ErrorState( { title, subtitle, code, onRetry } ) { |
| 231 |
return ( |
| 232 |
<div className="bda-empty"> |
| 233 |
<EmptyArt kind="error" /> |
| 234 |
<h3>{ title || __( "Couldn't load this section", "betterdocs" ) }</h3> |
| 235 |
<p>{ subtitle || __( "We hit a snag fetching this data. This is usually temporary — try again, or check that the analytics service is reachable.", "betterdocs" ) }</p> |
| 236 |
{ onRetry && ( |
| 237 |
<div className="bda-empty-actions"> |
| 238 |
<button className="bda-btn primary" onClick={ onRetry }><I.RefreshCw size={ 13 } />{ __( "Try again", "betterdocs" ) }</button> |
| 239 |
</div> |
| 240 |
) } |
| 241 |
{ code && <code className="bda-empty-code">{ code }</code> } |
| 242 |
</div> |
| 243 |
); |
| 244 |
} |
| 245 |
|
| 246 |
export function SkeletonLine( { w = "100%", h = 10, className, style } ) { |
| 247 |
return <div className={ cn( "bda-skel", className ) } style={ { width: w, height: h, ...style } } />; |
| 248 |
} |
| 249 |
|
| 250 |
export function KpiSkeleton() { |
| 251 |
return ( |
| 252 |
<div className="bda-kpi"> |
| 253 |
<SkeletonLine w="50%" h={ 10 } style={ { marginBottom: 12 } } /> |
| 254 |
<div className="bda-kpi-row"> |
| 255 |
<div> |
| 256 |
<SkeletonLine w={ 120 } h={ 26 } style={ { marginBottom: 8 } } /> |
| 257 |
<SkeletonLine w={ 80 } h={ 10 } /> |
| 258 |
</div> |
| 259 |
<SkeletonLine w={ 90 } h={ 36 } /> |
| 260 |
</div> |
| 261 |
</div> |
| 262 |
); |
| 263 |
} |
| 264 |
|
| 265 |
export function TableRowSkeleton( { cols = 6 } ) { |
| 266 |
return ( |
| 267 |
<tr> |
| 268 |
{ Array.from( { length: cols } ).map( ( _, i ) => ( |
| 269 |
<td key={ i }><SkeletonLine w={ i === 0 ? "70%" : "55%" } h={ 10 } /></td> |
| 270 |
) ) } |
| 271 |
</tr> |
| 272 |
); |
| 273 |
} |
| 274 |
|
| 275 |
export function Skeleton( { lines = 4 } ) { |
| 276 |
return ( |
| 277 |
<div style={ { display: "flex", flexDirection: "column", gap: 12, padding: 8 } }> |
| 278 |
{ Array.from( { length: lines } ).map( ( _, i ) => ( |
| 279 |
<SkeletonLine key={ i } h={ 12 } w={ i === 0 ? "70%" : "100%" } /> |
| 280 |
) ) } |
| 281 |
</div> |
| 282 |
); |
| 283 |
} |
| 284 |
|
| 285 |
/* Deterministic avatar descriptor from a name. */ |
| 286 |
export function authorFromName( name ) { |
| 287 |
const n = String( name || "?" ).trim(); |
| 288 |
let hue = 0; |
| 289 |
for ( let i = 0; i < n.length; i++ ) hue = ( hue * 31 + n.charCodeAt( i ) ) % 360; |
| 290 |
const initials = n.split( /\s+/ ).slice( 0, 2 ).map( ( p ) => p[ 0 ] || "" ).join( "" ).toUpperCase() || "?"; |
| 291 |
return { hue, initials, name: n }; |
| 292 |
} |
| 293 |
|