mirror of
https://dev.azure.com/tstanciu94/Packages/_git/analytics-switch
synced 2025-08-10 21:14:34 +03:00
Merged PR 106: chore: upgrade package version to 1.1.0 and refactor codebase to TypeScript
chore: upgrade package version to 1.1.0 and refactor codebase to TypeScript - Updated package version in package.json from 1.0.1 to 1.1.0. - Changed main entry point to use built files in dist directory. - Removed Babel configuration and build scripts in favor of tsup. - Deleted copy-files script as it is no longer needed. - Refactored AnalyticsSwitch component from JavaScript to TypeScript. - Added unit tests for AnalyticsSwitch component. - Created new TypeScript files for UmamiAnalytics and MatomoAnalytics components. - Implemented custom hooks for loading Umami and Matomo scripts. - Added TypeScript types for analytics components and props. - Set up TypeScript configuration and build process with tsup. - Configured Vitest for testing with setup files and coverage reporting.
This commit is contained in:
parent
8bebfc142e
commit
14f1b11263
5
.gitignore
vendored
5
.gitignore
vendored
@ -20,4 +20,7 @@ dist
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
example/package-lock.json
|
example/package-lock.json
|
||||||
|
|
||||||
|
.claude
|
||||||
|
CLAUDE.md
|
54
CHANGELOG.md
Normal file
54
CHANGELOG.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.1.0] - 2025-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 🎯 **TypeScript support** - Complete migration from JavaScript to TypeScript
|
||||||
|
- 📦 **Modern build system** - Replaced Babel with tsup for ultra-fast builds using esbuild
|
||||||
|
- 🧪 **Comprehensive test suite** - Added Vitest with React Testing Library
|
||||||
|
- 📝 **Type definitions** - Full TypeScript type exports for better developer experience
|
||||||
|
- ⚡ **Multiple output formats** - CommonJS, ES Modules, and TypeScript declarations
|
||||||
|
- 🔧 **Modern tooling** - Updated to latest dependencies and build tools
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Migrated from JavaScript to TypeScript with full backward compatibility
|
||||||
|
- Updated build output directory from `build/` to `dist/`
|
||||||
|
- Replaced `prop-types` dependency with TypeScript types
|
||||||
|
- Updated package exports to support both CommonJS and ES Modules
|
||||||
|
- Improved script cleanup logic in analytics hooks
|
||||||
|
- Modernized development scripts and workflows
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed Babel configuration and dependencies in favor of tsup
|
||||||
|
- Removed `prop-types` dependency (replaced with TypeScript types)
|
||||||
|
- Removed custom `copy-files.js` script in favor of modern build system
|
||||||
|
- Removed old build and publish scripts
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved error handling for script injection and cleanup
|
||||||
|
- Better memory management for analytics scripts
|
||||||
|
|
||||||
|
## [1.0.1] - 2023-XX-XX
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Minor bug fixes and improvements
|
||||||
|
|
||||||
|
## [1.0.0] - 2023-XX-XX
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial release of analytics-switch component
|
||||||
|
- Support for Umami analytics integration
|
||||||
|
- Support for Matomo analytics integration
|
||||||
|
- Conditional rendering based on provider configuration
|
||||||
|
- React hooks for script injection and cleanup
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Tudor Stanciu
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
201
README.md
201
README.md
@ -1,20 +1,207 @@
|
|||||||
# analytics-switch
|
# @flare/analytics-switch
|
||||||
|
|
||||||
## Install
|
A TypeScript React component library that provides a unified interface for integrating multiple analytics providers (Umami and Matomo) into React applications.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🎯 **Multi-provider support** - Umami and Matomo analytics in one component
|
||||||
|
- 🔧 **TypeScript** - Full type safety and IntelliSense support
|
||||||
|
- 📦 **Zero dependencies** - Only peer dependencies on React
|
||||||
|
- ⚡ **Performance** - Conditional script loading based on configuration
|
||||||
|
- 🧪 **Tested** - Comprehensive test suite with Vitest
|
||||||
|
- 📱 **Universal** - Works in both browser and server-side rendering environments
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
// with npm
|
# with npm
|
||||||
npm i --save @flare/analytics-switch --registry https://lab.code-rove.com/public-node-registry
|
npm i --save @flare/analytics-switch --registry https://lab.code-rove.com/public-node-registry
|
||||||
|
|
||||||
// with yarn
|
# with yarn
|
||||||
yarn add @flare/analytics-switch --registry https://lab.code-rove.com/public-node-registry
|
yarn add @flare/analytics-switch --registry https://lab.code-rove.com/public-node-registry
|
||||||
|
|
||||||
|
# with pnpm
|
||||||
|
pnpm add @flare/analytics-switch --registry https://lab.code-rove.com/public-node-registry
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```jsx
|
### Basic Usage
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React from "react";
|
||||||
import { AnalyticsSwitch } from "@flare/analytics-switch";
|
import { AnalyticsSwitch } from "@flare/analytics-switch";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<AnalyticsSwitch
|
||||||
|
umami={{
|
||||||
|
enabled: true,
|
||||||
|
source: "https://your-umami-instance.com/script.js",
|
||||||
|
websiteId: "your-website-id"
|
||||||
|
}}
|
||||||
|
matomo={{
|
||||||
|
enabled: true,
|
||||||
|
source: "https://your-matomo-instance.com/",
|
||||||
|
websiteId: "1"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Your app content */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**1.0.0**
|
### TypeScript Usage
|
||||||
This version includes the initial version of analytics-switch component.
|
|
||||||
|
The component provides full TypeScript support with proper type definitions:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
AnalyticsSwitch,
|
||||||
|
type AnalyticsSwitchProps
|
||||||
|
} from "@flare/analytics-switch";
|
||||||
|
|
||||||
|
const analyticsConfig: AnalyticsSwitchProps = {
|
||||||
|
disabled: false, // Optional: disable all analytics
|
||||||
|
umami: {
|
||||||
|
enabled: process.env.NODE_ENV === "production",
|
||||||
|
source: "https://analytics.yoursite.com/script.js",
|
||||||
|
websiteId: "abc123"
|
||||||
|
},
|
||||||
|
matomo: {
|
||||||
|
enabled: process.env.NODE_ENV === "production",
|
||||||
|
source: "https://matomo.yoursite.com/",
|
||||||
|
websiteId: "1"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return <AnalyticsSwitch {...analyticsConfig} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Analytics
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { AnalyticsSwitch } from "@flare/analytics-switch";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const isProduction = process.env.NODE_ENV === "production";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnalyticsSwitch
|
||||||
|
disabled={!isProduction} // Disable all analytics in non-production
|
||||||
|
umami={{
|
||||||
|
enabled: true,
|
||||||
|
source: "https://umami.example.com/script.js",
|
||||||
|
websiteId: "website-id"
|
||||||
|
}}
|
||||||
|
// Matomo is optional - omit if not needed
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### AnalyticsSwitchProps
|
||||||
|
|
||||||
|
| Property | Type | Required | Description |
|
||||||
|
| ---------- | ------------------- | -------- | --------------------------------- |
|
||||||
|
| `disabled` | `boolean` | No | Disable all analytics when `true` |
|
||||||
|
| `umami` | `AnalyticsProvider` | No | Umami analytics configuration |
|
||||||
|
| `matomo` | `AnalyticsProvider` | No | Matomo analytics configuration |
|
||||||
|
|
||||||
|
### AnalyticsProvider
|
||||||
|
|
||||||
|
| Property | Type | Required | Description |
|
||||||
|
| ----------- | --------- | -------- | -------------------------------------------- |
|
||||||
|
| `enabled` | `boolean` | Yes | Whether this analytics provider is enabled |
|
||||||
|
| `source` | `string` | Yes | Script source URL for the analytics provider |
|
||||||
|
| `websiteId` | `string` | Yes | Website/site ID for tracking |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm, yarn, or pnpm
|
||||||
|
|
||||||
|
### Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Build the library
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Run tests with UI
|
||||||
|
npm run test:ui
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
npm run typecheck
|
||||||
|
|
||||||
|
# Development mode with watch
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Lint and format
|
||||||
|
npm run lint
|
||||||
|
npm run lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building
|
||||||
|
|
||||||
|
This library uses [tsup](https://tsup.egoist.dev/) for ultra-fast builds with esbuild:
|
||||||
|
|
||||||
|
- **CommonJS**: `dist/index.js`
|
||||||
|
- **ES Modules**: `dist/index.mjs`
|
||||||
|
- **TypeScript declarations**: `dist/index.d.ts`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Tests are written with [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # Run tests
|
||||||
|
npm run test:ui # Run with Vitest UI
|
||||||
|
npm run test:coverage # Run with coverage report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Patch version
|
||||||
|
npm run publish:patch
|
||||||
|
|
||||||
|
# Minor version
|
||||||
|
npm run publish:minor
|
||||||
|
|
||||||
|
# Major version
|
||||||
|
npm run publish:major
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
See [CHANGELOG.md](./CHANGELOG.md) for version history.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT - see [LICENSE](./LICENSE) file for details.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
||||||
|
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open a Pull Request
|
||||||
|
7964
package-lock.json
generated
7964
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
77
package.json
77
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@flare/analytics-switch",
|
"name": "@flare/analytics-switch",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"description": "Analytics switch that includes integrations with Umami and Matomo",
|
"description": "Analytics switch that includes integrations with Umami and Matomo",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Tudor Stanciu",
|
"name": "Tudor Stanciu",
|
||||||
@ -19,48 +19,55 @@
|
|||||||
"umami",
|
"umami",
|
||||||
"matomo"
|
"matomo"
|
||||||
],
|
],
|
||||||
"main": "./src/index.js",
|
"main": "./dist/index.js",
|
||||||
"babel": {
|
"module": "./dist/index.mjs",
|
||||||
"presets": [
|
"types": "./dist/index.d.ts",
|
||||||
"@babel/preset-react"
|
"exports": {
|
||||||
]
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prebuild": "rimraf build",
|
"build": "tsup",
|
||||||
"build": "npm run build:cjs && npm run build:copy-files",
|
"dev": "tsup --watch",
|
||||||
"build:cjs": "babel src -d build --copy-files",
|
"test": "vitest",
|
||||||
"build:copy-files": "node ./scripts/copy-files.js",
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "prettier --check .",
|
||||||
|
"lint:fix": "prettier --write .",
|
||||||
"precommit": "lint-staged",
|
"precommit": "lint-staged",
|
||||||
"prepare": "husky install",
|
"prepare": "husky install",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"push": "npm publish --registry https://lab.code-rove.com/public-node-registry",
|
||||||
"prepush": "npm run build",
|
"prepublishOnly": "npm test && npm run build",
|
||||||
"push": "cd build && npm publish --registry https://lab.code-rove.com/public-node-registry",
|
"publish:patch": "npm version patch && npm run push",
|
||||||
"push:major": "npm run version:major && npm run push",
|
"publish:minor": "npm version minor && npm run push",
|
||||||
"push:minor": "npm run version:minor && npm run push",
|
"publish:major": "npm version major && npm run push"
|
||||||
"push:patch": "npm run version:patch && npm run push",
|
|
||||||
"version:major": "npm version major --no-git-tag-version",
|
|
||||||
"version:minor": "npm version minor --no-git-tag-version",
|
|
||||||
"version:patch": "npm version patch --no-git-tag-version"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/cli": "^7.16.7",
|
"@testing-library/react": "^16.0.1",
|
||||||
"@babel/core": "^7.16.7",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@babel/node": "^7.16.7",
|
"@types/react": "^18.3.12",
|
||||||
"@babel/preset-env": "^7.16.7",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@babel/preset-react": "^7.8.0",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"rimraf": "^3.0.2",
|
"@vitest/coverage-v8": "^2.1.8",
|
||||||
"fs-extra": "^10.0.0",
|
"@vitest/ui": "^2.1.8",
|
||||||
"husky": "^7.0.4",
|
"husky": "^9.1.6",
|
||||||
"lint-staged": "^12.2.2",
|
"jsdom": "^25.0.1",
|
||||||
"prettier": "^2.5.1"
|
"lint-staged": "^15.2.10",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vitest": "^2.1.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.2.0",
|
"react": ">=16.8.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": ">=16.8.0"
|
||||||
"react-scripts": "5.0.1"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"prop-types": "^15.8.1"
|
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
|
@ -1,87 +0,0 @@
|
|||||||
/* eslint-disable no-console */
|
|
||||||
const path = require("path");
|
|
||||||
const fse = require("fs-extra");
|
|
||||||
const glob = require("glob");
|
|
||||||
|
|
||||||
const packagePath = process.cwd();
|
|
||||||
const buildPath = path.join(packagePath, "./build");
|
|
||||||
const srcPath = path.join(packagePath, "./src");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Puts a package.json into every immediate child directory of rootDir.
|
|
||||||
* That package.json contains information about esm for bundlers so that imports
|
|
||||||
* like import Typography from '@material-ui/core/Typography' are tree-shakeable.
|
|
||||||
*
|
|
||||||
* It also tests that an this import can be used in typescript by checking
|
|
||||||
* if an index.d.ts is present at that path.
|
|
||||||
*
|
|
||||||
* @param {string} rootDir
|
|
||||||
*/
|
|
||||||
// async function createModulePackages({ from, to }) {
|
|
||||||
// const directoryPackages = glob.sync('*/index.js', { cwd: from }).map(path.dirname);
|
|
||||||
|
|
||||||
// await Promise.all(
|
|
||||||
// directoryPackages.map(async directoryPackage => {
|
|
||||||
// const packageJson = {
|
|
||||||
// sideEffects: false,
|
|
||||||
// module: path.join('../esm', directoryPackage, 'index.js'),
|
|
||||||
// typings: './index.d.ts',
|
|
||||||
// };
|
|
||||||
// const packageJsonPath = path.join(to, directoryPackage, 'package.json');
|
|
||||||
|
|
||||||
// /*const [typingsExist] =*/ await Promise.all([
|
|
||||||
// //fse.exists(path.join(to, directoryPackage, 'index.d.ts')),
|
|
||||||
// fse.writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2)),
|
|
||||||
// ]);
|
|
||||||
|
|
||||||
// // if (!typingsExist) {
|
|
||||||
// // throw new Error(`index.d.ts for ${directoryPackage} is missing`);
|
|
||||||
// // }
|
|
||||||
|
|
||||||
// return packageJsonPath;
|
|
||||||
// }),
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
async function createPackageFile() {
|
|
||||||
const packageData = await fse.readFile(
|
|
||||||
path.resolve(packagePath, "./package.json"),
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
const { nyc, scripts, devDependencies, workspaces, ...packageDataOther } =
|
|
||||||
JSON.parse(packageData);
|
|
||||||
const newPackageData = {
|
|
||||||
...packageDataOther,
|
|
||||||
private: false,
|
|
||||||
main: "./index.js"
|
|
||||||
};
|
|
||||||
const targetPath = path.resolve(buildPath, "./package.json");
|
|
||||||
|
|
||||||
await fse.writeFile(
|
|
||||||
targetPath,
|
|
||||||
JSON.stringify(newPackageData, null, 2),
|
|
||||||
"utf8"
|
|
||||||
);
|
|
||||||
console.log(`Created package.json in ${targetPath}`);
|
|
||||||
|
|
||||||
fse.copy("./README.md", `${buildPath}/README.md`, err => {
|
|
||||||
if (err) throw err;
|
|
||||||
console.log("README file was copied to destination");
|
|
||||||
});
|
|
||||||
|
|
||||||
return newPackageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
await createPackageFile();
|
|
||||||
|
|
||||||
//await createModulePackages({ from: srcPath, to: buildPath });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
@ -1,35 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import PropTypes from "prop-types";
|
|
||||||
import UmamiAnalytics from "./umami/UmamiAnalytics";
|
|
||||||
import MatomoAnalytics from "./matomo/MatomoAnalytics";
|
|
||||||
|
|
||||||
const AnalyticsSwitch = ({ disabled, umami, matomo }) => {
|
|
||||||
if (disabled) return "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{umami && umami.enabled && <UmamiAnalytics {...umami} />}
|
|
||||||
{matomo && matomo.enabled && <MatomoAnalytics {...matomo} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
AnalyticsSwitch.defaultProps = {
|
|
||||||
disabled: false
|
|
||||||
};
|
|
||||||
|
|
||||||
AnalyticsSwitch.propTypes = {
|
|
||||||
disabled: PropTypes.bool,
|
|
||||||
umami: PropTypes.shape({
|
|
||||||
enabled: PropTypes.bool.isRequired,
|
|
||||||
source: PropTypes.string.isRequired,
|
|
||||||
websiteId: PropTypes.string.isRequired
|
|
||||||
}),
|
|
||||||
matomo: PropTypes.shape({
|
|
||||||
enabled: PropTypes.bool.isRequired,
|
|
||||||
source: PropTypes.string.isRequired,
|
|
||||||
websiteId: PropTypes.string.isRequired
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnalyticsSwitch;
|
|
21
src/AnalyticsSwitch.tsx
Normal file
21
src/AnalyticsSwitch.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React from "react";
|
||||||
|
import UmamiAnalytics from "./umami/UmamiAnalytics";
|
||||||
|
import MatomoAnalytics from "./matomo/MatomoAnalytics";
|
||||||
|
import type { AnalyticsSwitchProps } from "./types";
|
||||||
|
|
||||||
|
const AnalyticsSwitch: React.FC<AnalyticsSwitchProps> = ({
|
||||||
|
disabled = false,
|
||||||
|
umami,
|
||||||
|
matomo
|
||||||
|
}) => {
|
||||||
|
if (disabled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{umami?.enabled && <UmamiAnalytics {...umami} />}
|
||||||
|
{matomo?.enabled && <MatomoAnalytics {...matomo} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AnalyticsSwitch;
|
117
src/__tests__/AnalyticsSwitch.test.tsx
Normal file
117
src/__tests__/AnalyticsSwitch.test.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import AnalyticsSwitch from "../AnalyticsSwitch";
|
||||||
|
import type { AnalyticsSwitchProps } from "../types";
|
||||||
|
|
||||||
|
// Mock the analytics components
|
||||||
|
vi.mock("../umami/UmamiAnalytics", () => ({
|
||||||
|
default: ({ source, websiteId }: { source: string; websiteId: string }) => (
|
||||||
|
<div
|
||||||
|
data-testid="umami-analytics"
|
||||||
|
data-source={source}
|
||||||
|
data-website-id={websiteId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../matomo/MatomoAnalytics", () => ({
|
||||||
|
default: ({ source, websiteId }: { source: string; websiteId: string }) => (
|
||||||
|
<div
|
||||||
|
data-testid="matomo-analytics"
|
||||||
|
data-source={source}
|
||||||
|
data-website-id={websiteId}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("AnalyticsSwitch", () => {
|
||||||
|
it("renders nothing when disabled", () => {
|
||||||
|
const props: AnalyticsSwitchProps = {
|
||||||
|
disabled: true,
|
||||||
|
umami: {
|
||||||
|
enabled: true,
|
||||||
|
source: "https://umami.example.com",
|
||||||
|
websiteId: "test-id"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<AnalyticsSwitch {...props} />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Umami analytics when enabled", () => {
|
||||||
|
const props: AnalyticsSwitchProps = {
|
||||||
|
umami: {
|
||||||
|
enabled: true,
|
||||||
|
source: "https://umami.example.com/script.js",
|
||||||
|
websiteId: "umami-test-id"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AnalyticsSwitch {...props} />);
|
||||||
|
|
||||||
|
const umamiElement = screen.getByTestId("umami-analytics");
|
||||||
|
expect(umamiElement).toHaveAttribute(
|
||||||
|
"data-source",
|
||||||
|
"https://umami.example.com/script.js"
|
||||||
|
);
|
||||||
|
expect(umamiElement).toHaveAttribute("data-website-id", "umami-test-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Matomo analytics when enabled", () => {
|
||||||
|
const props: AnalyticsSwitchProps = {
|
||||||
|
matomo: {
|
||||||
|
enabled: true,
|
||||||
|
source: "https://matomo.example.com/",
|
||||||
|
websiteId: "matomo-test-id"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AnalyticsSwitch {...props} />);
|
||||||
|
|
||||||
|
const matomoElement = screen.getByTestId("matomo-analytics");
|
||||||
|
expect(matomoElement).toHaveAttribute(
|
||||||
|
"data-source",
|
||||||
|
"https://matomo.example.com/"
|
||||||
|
);
|
||||||
|
expect(matomoElement).toHaveAttribute("data-website-id", "matomo-test-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders both analytics when both are enabled", () => {
|
||||||
|
const props: AnalyticsSwitchProps = {
|
||||||
|
umami: {
|
||||||
|
enabled: true,
|
||||||
|
source: "https://umami.example.com/script.js",
|
||||||
|
websiteId: "umami-test-id"
|
||||||
|
},
|
||||||
|
matomo: {
|
||||||
|
enabled: true,
|
||||||
|
source: "https://matomo.example.com/",
|
||||||
|
websiteId: "matomo-test-id"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<AnalyticsSwitch {...props} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("umami-analytics")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("matomo-analytics")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render analytics when they are disabled", () => {
|
||||||
|
const props: AnalyticsSwitchProps = {
|
||||||
|
umami: {
|
||||||
|
enabled: false,
|
||||||
|
source: "https://umami.example.com/script.js",
|
||||||
|
websiteId: "umami-test-id"
|
||||||
|
},
|
||||||
|
matomo: {
|
||||||
|
enabled: false,
|
||||||
|
source: "https://matomo.example.com/",
|
||||||
|
websiteId: "matomo-test-id"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { container } = render(<AnalyticsSwitch {...props} />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
1
src/__tests__/setup.ts
Normal file
1
src/__tests__/setup.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
84
src/__tests__/useMatomoScript.test.ts
Normal file
84
src/__tests__/useMatomoScript.test.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import useMatomoScript from "../matomo/useMatomoScript";
|
||||||
|
|
||||||
|
describe("useMatomoScript", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear any existing scripts
|
||||||
|
document.head.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up after each test
|
||||||
|
document.head.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates and appends Matomo script to document head", () => {
|
||||||
|
const source = "https://matomo.example.com/";
|
||||||
|
const websiteId = "1";
|
||||||
|
|
||||||
|
renderHook(() => useMatomoScript(source, websiteId));
|
||||||
|
|
||||||
|
const scripts = document.head.querySelectorAll("script");
|
||||||
|
expect(scripts.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Find the inline script that was injected by our hook
|
||||||
|
const inlineScript = Array.from(scripts).find(script =>
|
||||||
|
script.text.includes("_paq")
|
||||||
|
);
|
||||||
|
expect(inlineScript).toBeDefined();
|
||||||
|
expect(inlineScript!.text).toContain(
|
||||||
|
"var _paq = (window._paq = window._paq || []);"
|
||||||
|
);
|
||||||
|
expect(inlineScript!.text).toContain(`var u = "${source}";`);
|
||||||
|
expect(inlineScript!.text).toContain(
|
||||||
|
`_paq.push(["setSiteId", "${websiteId}"]);`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes script when component unmounts", () => {
|
||||||
|
const source = "https://matomo.example.com/";
|
||||||
|
const websiteId = "1";
|
||||||
|
|
||||||
|
const { unmount } = renderHook(() => useMatomoScript(source, websiteId));
|
||||||
|
|
||||||
|
const initialScriptCount = document.head.querySelectorAll("script").length;
|
||||||
|
expect(initialScriptCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
const finalScriptCount = document.head.querySelectorAll("script").length;
|
||||||
|
expect(finalScriptCount).toBeLessThan(initialScriptCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates script when source or websiteId changes", () => {
|
||||||
|
const initialSource = "https://matomo.example.com/";
|
||||||
|
const initialWebsiteId = "1";
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ source, websiteId }) => useMatomoScript(source, websiteId),
|
||||||
|
{ initialProps: { source: initialSource, websiteId: initialWebsiteId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.head.querySelectorAll("script").length
|
||||||
|
).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
const newSource = "https://new-matomo.example.com/";
|
||||||
|
const newWebsiteId = "2";
|
||||||
|
|
||||||
|
rerender({ source: newSource, websiteId: newWebsiteId });
|
||||||
|
|
||||||
|
const scripts = document.head.querySelectorAll("script");
|
||||||
|
expect(scripts.length).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
|
// Find the current inline script
|
||||||
|
const currentScript = Array.from(scripts).find(script =>
|
||||||
|
script.text.includes("_paq")
|
||||||
|
);
|
||||||
|
expect(currentScript).toBeDefined();
|
||||||
|
expect(currentScript!.text).toContain(`var u = "${newSource}";`);
|
||||||
|
expect(currentScript!.text).toContain(
|
||||||
|
`_paq.push(["setSiteId", "${newWebsiteId}"]);`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
67
src/__tests__/useUmamiScript.test.ts
Normal file
67
src/__tests__/useUmamiScript.test.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import useUmamiScript from "../umami/useUmamiScript";
|
||||||
|
|
||||||
|
describe("useUmamiScript", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear any existing scripts
|
||||||
|
document.head.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Clean up after each test
|
||||||
|
document.head.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates and appends Umami script to document head", () => {
|
||||||
|
const source = "https://umami.example.com/script.js";
|
||||||
|
const websiteId = "test-website-id";
|
||||||
|
|
||||||
|
renderHook(() => useUmamiScript(source, websiteId));
|
||||||
|
|
||||||
|
const scripts = document.head.querySelectorAll("script");
|
||||||
|
expect(scripts).toHaveLength(1);
|
||||||
|
|
||||||
|
const script = scripts[0];
|
||||||
|
expect(script.src).toBe(source);
|
||||||
|
expect(script.getAttribute("data-website-id")).toBe(websiteId);
|
||||||
|
expect(script.async).toBe(true);
|
||||||
|
expect(script.defer).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes script when component unmounts", () => {
|
||||||
|
const source = "https://umami.example.com/script.js";
|
||||||
|
const websiteId = "test-website-id";
|
||||||
|
|
||||||
|
const { unmount } = renderHook(() => useUmamiScript(source, websiteId));
|
||||||
|
|
||||||
|
expect(document.head.querySelectorAll("script")).toHaveLength(1);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(document.head.querySelectorAll("script")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates script when source or websiteId changes", () => {
|
||||||
|
const initialSource = "https://umami.example.com/script.js";
|
||||||
|
const initialWebsiteId = "test-website-id-1";
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ source, websiteId }) => useUmamiScript(source, websiteId),
|
||||||
|
{ initialProps: { source: initialSource, websiteId: initialWebsiteId } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(document.head.querySelectorAll("script")).toHaveLength(1);
|
||||||
|
|
||||||
|
const newSource = "https://new-umami.example.com/script.js";
|
||||||
|
const newWebsiteId = "test-website-id-2";
|
||||||
|
|
||||||
|
rerender({ source: newSource, websiteId: newWebsiteId });
|
||||||
|
|
||||||
|
const scripts = document.head.querySelectorAll("script");
|
||||||
|
expect(scripts).toHaveLength(1);
|
||||||
|
|
||||||
|
const script = scripts[0];
|
||||||
|
expect(script.src).toBe(newSource);
|
||||||
|
expect(script.getAttribute("data-website-id")).toBe(newWebsiteId);
|
||||||
|
});
|
||||||
|
});
|
@ -2,3 +2,8 @@ import AnalyticsSwitch from "./AnalyticsSwitch";
|
|||||||
|
|
||||||
export { AnalyticsSwitch };
|
export { AnalyticsSwitch };
|
||||||
export default AnalyticsSwitch;
|
export default AnalyticsSwitch;
|
||||||
|
export type {
|
||||||
|
AnalyticsSwitchProps,
|
||||||
|
AnalyticsProvider,
|
||||||
|
AnalyticsComponentProps
|
||||||
|
} from "./types";
|
@ -1,14 +0,0 @@
|
|||||||
import PropTypes from "prop-types";
|
|
||||||
import useMatomoScript from "./useMatomoScript";
|
|
||||||
|
|
||||||
const MatomoAnalytics = ({ source, websiteId }) => {
|
|
||||||
useMatomoScript(source, websiteId);
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
MatomoAnalytics.propTypes = {
|
|
||||||
source: PropTypes.string.isRequired,
|
|
||||||
websiteId: PropTypes.string.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MatomoAnalytics;
|
|
13
src/matomo/MatomoAnalytics.tsx
Normal file
13
src/matomo/MatomoAnalytics.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import useMatomoScript from "./useMatomoScript";
|
||||||
|
import type { AnalyticsComponentProps } from "../types";
|
||||||
|
|
||||||
|
const MatomoAnalytics: React.FC<AnalyticsComponentProps> = ({
|
||||||
|
source,
|
||||||
|
websiteId
|
||||||
|
}) => {
|
||||||
|
useMatomoScript(source, websiteId);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MatomoAnalytics;
|
@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
const useMatomoScript = (source, websiteId) => {
|
const useMatomoScript = (source: string, websiteId: string): void => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.text = `var _paq = (window._paq = window._paq || []);
|
script.text = `var _paq = (window._paq = window._paq || []);
|
||||||
@ -22,7 +22,9 @@ const useMatomoScript = (source, websiteId) => {
|
|||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.head.removeChild(script);
|
if (document.head.contains(script)) {
|
||||||
|
document.head.removeChild(script);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [source, websiteId]);
|
}, [source, websiteId]);
|
||||||
};
|
};
|
16
src/types.ts
Normal file
16
src/types.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export interface AnalyticsProvider {
|
||||||
|
enabled: boolean;
|
||||||
|
source: string;
|
||||||
|
websiteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSwitchProps {
|
||||||
|
disabled?: boolean;
|
||||||
|
umami?: AnalyticsProvider;
|
||||||
|
matomo?: AnalyticsProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsComponentProps {
|
||||||
|
source: string;
|
||||||
|
websiteId: string;
|
||||||
|
}
|
@ -1,14 +0,0 @@
|
|||||||
import PropTypes from "prop-types";
|
|
||||||
import useUmamiScript from "./useUmamiScript";
|
|
||||||
|
|
||||||
const UmamiAnalytics = ({ source, websiteId }) => {
|
|
||||||
useUmamiScript(source, websiteId);
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
UmamiAnalytics.propTypes = {
|
|
||||||
source: PropTypes.string.isRequired,
|
|
||||||
websiteId: PropTypes.string.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default UmamiAnalytics;
|
|
13
src/umami/UmamiAnalytics.tsx
Normal file
13
src/umami/UmamiAnalytics.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
import useUmamiScript from "./useUmamiScript";
|
||||||
|
import type { AnalyticsComponentProps } from "../types";
|
||||||
|
|
||||||
|
const UmamiAnalytics: React.FC<AnalyticsComponentProps> = ({
|
||||||
|
source,
|
||||||
|
websiteId
|
||||||
|
}) => {
|
||||||
|
useUmamiScript(source, websiteId);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UmamiAnalytics;
|
@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
const useUmamiScript = (src, websiteId) => {
|
const useUmamiScript = (src: string, websiteId: string): void => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.async = true;
|
script.async = true;
|
||||||
@ -11,7 +11,9 @@ const useUmamiScript = (src, websiteId) => {
|
|||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.head.removeChild(script);
|
if (document.head.contains(script)) {
|
||||||
|
document.head.removeChild(script);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [src, websiteId]);
|
}, [src, websiteId]);
|
||||||
};
|
};
|
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": ["vitest/globals", "@testing-library/jest-dom"],
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Declaration */
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"outDir": "./dist"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.*", "**/*.spec.*"]
|
||||||
|
}
|
13
tsup.config.ts
Normal file
13
tsup.config.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs", "esm"],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
sourcemap: true,
|
||||||
|
external: ["react", "react-dom"],
|
||||||
|
esbuildOptions: options => {
|
||||||
|
options.jsx = "automatic";
|
||||||
|
}
|
||||||
|
});
|
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: ["./src/__tests__/setup.ts"],
|
||||||
|
coverage: {
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
exclude: ["node_modules/", "src/__tests__/"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user