This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
STATS_ENDPOINT=https://api.tinybird.co
|
||||
STATS_TOKEN=p.ey....
|
||||
STATS_LOCAL_ENDPOINT=http://localhost:7181
|
||||
STATS_LOCAL_TOKEN=p.ey...
|
||||
STATS_LOCAL_DATASOURCE=analytics_events
|
||||
@@ -0,0 +1 @@
|
||||
tailwind.config.cjs
|
||||
@@ -0,0 +1,70 @@
|
||||
/* eslint-env node */
|
||||
const tailwindCssConfig = `${__dirname}/../admin/src/index.css`;
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: [
|
||||
'plugin:ghost/ts',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
],
|
||||
plugins: [
|
||||
'ghost',
|
||||
'react-refresh',
|
||||
'tailwindcss'
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
},
|
||||
tailwindcss: {
|
||||
config: tailwindCssConfig
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
// Sort multiple import lines into alphabetical groups
|
||||
'ghost/sort-imports-es6-autofix/sort-imports-es6': ['error', {
|
||||
memberSyntaxSortOrder: ['none', 'all', 'single', 'multiple']
|
||||
}],
|
||||
'no-restricted-imports': ['error', {
|
||||
paths: [{
|
||||
name: '@tryghost/shade',
|
||||
message: 'Import from layered subpaths instead (components/primitives/patterns/utils/app/tokens).'
|
||||
}]
|
||||
}],
|
||||
|
||||
// Enforce kebab-case (lowercase with hyphens) for all filenames
|
||||
'ghost/filenames/match-regex': ['error', '^[a-z0-9.-]+$', false],
|
||||
|
||||
// TODO: re-enable this (maybe fixed fast refresh?)
|
||||
'react-refresh/only-export-components': 'off',
|
||||
|
||||
// Suppress errors for missing 'import React' in JSX files, as we don't need it
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
// Ignore prop-types for now
|
||||
'react/prop-types': 'off',
|
||||
|
||||
// TODO: re-enable these if deemed useful
|
||||
'@typescript-eslint/no-non-null-assertion': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
|
||||
// custom react rules
|
||||
'react/jsx-sort-props': ['error', {
|
||||
reservedFirst: true,
|
||||
callbacksLast: true,
|
||||
shorthandLast: true,
|
||||
locale: 'en'
|
||||
}],
|
||||
'react/button-has-type': 'error',
|
||||
'react/no-array-index-key': 'error',
|
||||
'react/jsx-key': 'off',
|
||||
|
||||
'tailwindcss/classnames-order': 'error',
|
||||
'tailwindcss/enforces-negative-arbitrary-values': 'warn',
|
||||
'tailwindcss/enforces-shorthand': 'warn',
|
||||
'tailwindcss/migration-from-tailwind-2': 'warn',
|
||||
'tailwindcss/no-arbitrary-value': 'off',
|
||||
'tailwindcss/no-custom-classname': 'off',
|
||||
'tailwindcss/no-contradicting-classname': 'error'
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
dist
|
||||
types
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,93 @@
|
||||
# Ghost Stats App
|
||||
|
||||
Ghost Admin Stats micro-frontend that provides analytics and insights for Ghost sites.
|
||||
|
||||
## Features
|
||||
|
||||
### Top Content Analytics
|
||||
- **Growth Tab**: Shows which posts and pages drove the most member conversions
|
||||
- **Web Tab**: Shows which posts and pages received the most visitors
|
||||
|
||||
### URL Linking
|
||||
All content in the analytics tables is now clickable:
|
||||
- **Posts**: Click to view detailed post analytics
|
||||
- **Pages**: Click to view the page on the frontend site
|
||||
- **System Pages**: Click to view homepage, tag pages, author pages, etc. on the frontend site
|
||||
|
||||
The app automatically determines the appropriate action:
|
||||
- Posts with analytics data → Navigate to post analytics page
|
||||
- Pages and system pages → Open frontend URL in new tab
|
||||
|
||||
### Supported System Pages
|
||||
- Homepage (`/`)
|
||||
- Tag pages (`/tag/slug/`, `/tags/slug/`)
|
||||
- Author pages (`/author/slug/`, `/authors/slug/`)
|
||||
- Custom pages and other frontend URLs
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (version as specified in the root package.json)
|
||||
- Yarn
|
||||
|
||||
### Setup
|
||||
|
||||
This app is part of the Ghost monorepo. After cloning the Ghost repository:
|
||||
|
||||
```bash
|
||||
# Install dependencies from the root directory
|
||||
pnpm
|
||||
|
||||
# Run pnpm dev in the root of the repo
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
This will create a production build in the `dist` directory.
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Run only unit tests
|
||||
pnpm test:unit
|
||||
|
||||
# Run tests in watch mode during development
|
||||
pnpm test:watch
|
||||
|
||||
# Run tests with coverage report
|
||||
pnpm test:coverage
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
# Lint all files
|
||||
pnpm lint
|
||||
|
||||
# Lint only source code
|
||||
pnpm lint:code
|
||||
|
||||
# Lint only test files
|
||||
pnpm lint:test
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT - See LICENSE file for details.
|
||||
|
||||
## URL Utilities
|
||||
|
||||
The app includes URL helper utilities in `src/utils/url-helpers.ts`:
|
||||
|
||||
- `getFrontendUrl()`: Generate full frontend URLs from attribution paths
|
||||
- `shouldMakeClickable()`: Determine if content should be clickable
|
||||
- `getClickHandler()`: Get appropriate click handler for content type
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ghost Traffic Analytics</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/standalone.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "@tryghost/stats",
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/TryGhost/Ghost/tree/main/apps/stats"
|
||||
},
|
||||
"author": "Ghost Foundation",
|
||||
"files": [
|
||||
"LICENSE",
|
||||
"README.md",
|
||||
"dist/"
|
||||
],
|
||||
"main": "./dist/stats.umd.cjs",
|
||||
"module": "./dist/stats.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/stats.js",
|
||||
"require": "./dist/stats.umd.cjs"
|
||||
},
|
||||
"./api": "./src/api.ts"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"dev:start": "vite",
|
||||
"test": "pnpm test:unit --coverage",
|
||||
"test:unit": "vitest run test/unit",
|
||||
"test:acceptance": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' playwright test",
|
||||
"test:acceptance:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 pnpm test:acceptance --headed",
|
||||
"test:acceptance:full": "ALL_BROWSERS=1 pnpm test:acceptance",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "pnpm run lint:code && pnpm run lint:test",
|
||||
"lint:code": "eslint --ext .js,.ts,.cjs,.tsx --cache src",
|
||||
"lint:code:fix": "eslint --ext .js,.ts,.cjs,.tsx --cache --fix src",
|
||||
"lint:test": "eslint -c test/.eslintrc.cjs --ext .js,.ts,.cjs,.tsx --cache test",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "9.9.0",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@tanstack/react-query": "4.36.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "14.3.1",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/react": "18.3.28",
|
||||
"@types/react-svg-map": "2.1.4",
|
||||
"@vitest/coverage-v8": "^1.6.1",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"dotenv": "17.3.1",
|
||||
"msw": "2.12.14",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"vite": "5.4.21",
|
||||
"vite-plugin-svgr": "4.5.0",
|
||||
"vitest": "1.6.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@svg-maps/world": "1.0.1",
|
||||
"@tryghost/admin-x-framework": "workspace:*",
|
||||
"@tryghost/shade": "workspace:*",
|
||||
"i18n-iso-countries": "7.14.0",
|
||||
"moment": "2.24.0",
|
||||
"moment-timezone": "0.5.45",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-svg-map": "2.2.0"
|
||||
},
|
||||
"nx": {
|
||||
"targets": {
|
||||
"dev": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"test:unit": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
},
|
||||
"test:acceptance": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {adminXPlaywrightConfig} from '@tryghost/admin-x-framework/playwright';
|
||||
|
||||
export default adminXPlaywrightConfig();
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Public API for cross-package imports.
|
||||
* Admin uses these exports instead of reaching into src/ directly.
|
||||
*/
|
||||
export {default as GlobalDataProvider} from './providers/global-data-provider';
|
||||
export {routes} from './routes';
|
||||
@@ -0,0 +1,34 @@
|
||||
import GlobalDataProvider from './providers/global-data-provider';
|
||||
import StatsErrorBoundary from '@components/errors/stats-error-boundary';
|
||||
import {APP_ROUTE_PREFIX, routes} from '@src/routes';
|
||||
import {AppProvider, BaseAppProps, FrameworkProvider, Outlet, RouterProvider} from '@tryghost/admin-x-framework';
|
||||
import {ShadeApp} from '@tryghost/shade/app';
|
||||
|
||||
export {useAppContext} from '@tryghost/admin-x-framework';
|
||||
|
||||
const App: React.FC<BaseAppProps> = ({framework, designSystem, appSettings}) => {
|
||||
return (
|
||||
<FrameworkProvider
|
||||
{...framework}
|
||||
queryClientOptions={{
|
||||
staleTime: 0, // Always consider data stale (matches Ember admin route behavior)
|
||||
refetchOnMount: true, // Always refetch when component mounts (matches Ember route model)
|
||||
refetchOnWindowFocus: false // Disable window focus refetch (Ember admin doesn't have this)
|
||||
}}
|
||||
>
|
||||
<AppProvider appSettings={appSettings}>
|
||||
<RouterProvider prefix={APP_ROUTE_PREFIX} routes={routes}>
|
||||
<StatsErrorBoundary>
|
||||
<GlobalDataProvider>
|
||||
<ShadeApp className="shade-stats app-container" darkMode={designSystem.darkMode} fetchKoenigLexical={null}>
|
||||
<Outlet />
|
||||
</ShadeApp>
|
||||
</GlobalDataProvider>
|
||||
</StatsErrorBoundary>
|
||||
</RouterProvider>
|
||||
</AppProvider>
|
||||
</FrameworkProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,6 @@
|
||||
import './styles/index.css';
|
||||
import App from './app';
|
||||
|
||||
export {
|
||||
App as AdminXApp
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import {RouteObject, lazyComponent} from '@tryghost/admin-x-framework';
|
||||
|
||||
export const APP_ROUTE_PREFIX = '/';
|
||||
|
||||
export const routes: RouteObject[] = [
|
||||
{
|
||||
path: 'analytics',
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyComponent(() => import('./views/Stats/Overview'))
|
||||
},
|
||||
{
|
||||
path: 'web',
|
||||
lazy: lazyComponent(() => import('./views/Stats/Web'))
|
||||
},
|
||||
{
|
||||
path: 'growth',
|
||||
lazy: lazyComponent(() => import('./views/Stats/Growth'))
|
||||
},
|
||||
{
|
||||
path: 'newsletters',
|
||||
lazy: lazyComponent(() => import('./views/Stats/Newsletters'))
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,20 @@
|
||||
import './styles/index.css';
|
||||
import App from './app';
|
||||
import renderShadeApp from '@tryghost/admin-x-framework/test/render-shade';
|
||||
import {AppSettings} from '@tryghost/admin-x-framework';
|
||||
|
||||
// Use test overrides if available, otherwise use defaults
|
||||
const defaultAppSettings: AppSettings = {
|
||||
paidMembersEnabled: true,
|
||||
newslettersEnabled: true,
|
||||
analytics: {
|
||||
emailTrackOpens: true,
|
||||
emailTrackClicks: true,
|
||||
membersTrackSources: true,
|
||||
outboundLinkTagging: true,
|
||||
webAnalytics: true
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
renderShadeApp(App, {appSettings: defaultAppSettings} as any);
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: ['ghost'],
|
||||
extends: [
|
||||
'plugin:ghost/ts-test'
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import {setupShadeMocks} from '@tryghost/admin-x-framework/test/setup';
|
||||
|
||||
// Set up common mocks for shade components
|
||||
setupShadeMocks();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"declarationDir": "./types",
|
||||
"emitDeclarationOnly": true,
|
||||
"tsBuildInfoFile": "./types/tsconfig.tsbuildinfo",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@src/*": ["*"],
|
||||
"@assets/*": ["assets/*"],
|
||||
"@components/*": ["components/*"],
|
||||
"@hooks/*": ["hooks/*"],
|
||||
"@utils/*": ["utils/*"],
|
||||
"@views/*": ["views/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import adminXViteConfig from '@tryghost/admin-x-framework/vite';
|
||||
import pkg from './package.json';
|
||||
import svgr from 'vite-plugin-svgr';
|
||||
import {resolve} from 'path';
|
||||
|
||||
export default (function viteConfig() {
|
||||
return adminXViteConfig({
|
||||
packageName: pkg.name,
|
||||
plugins: [
|
||||
svgr()
|
||||
],
|
||||
entry: resolve(__dirname, 'src/index.tsx'),
|
||||
overrides: {
|
||||
test: {
|
||||
include: [
|
||||
'./test/unit/**/*',
|
||||
'./src/**/*.test.ts'
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@src': resolve(__dirname, './src'),
|
||||
'@assets': resolve(__dirname, './src/assets'),
|
||||
'@components': resolve(__dirname, './src/components'),
|
||||
'@hooks': resolve(__dirname, './src/hooks'),
|
||||
'@utils': resolve(__dirname, './src/utils'),
|
||||
'@views': resolve(__dirname, './src/views')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import {createVitestConfig} from '@tryghost/admin-x-framework/test/vitest-config';
|
||||
import {resolve} from 'path';
|
||||
|
||||
export default createVitestConfig({
|
||||
aliases: {
|
||||
'@src': resolve(__dirname, './src'),
|
||||
'@assets': resolve(__dirname, './src/assets'),
|
||||
'@components': resolve(__dirname, './src/components'),
|
||||
'@hooks': resolve(__dirname, './src/hooks'),
|
||||
'@utils': resolve(__dirname, './src/utils'),
|
||||
'@views': resolve(__dirname, './src/views')
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user