([]);
+}
diff --git a/packages/account/feature-shell/src/lib/components/index.ts b/packages/account/feature-shell/src/lib/components/index.ts
index 5eda18b1..6c881e5f 100644
--- a/packages/account/feature-shell/src/lib/components/index.ts
+++ b/packages/account/feature-shell/src/lib/components/index.ts
@@ -3,4 +3,5 @@ export * from './contributor-card-list/contributor-card-list.component';
export * from './album-card-list/album-card-list.component';
export * from './editable-photo/editable-photo.component';
export * from './editable-roles/editable-roles.component';
+export * from './contributors/contributors.component';
export * from './social-icon/social-icon.component';
diff --git a/packages/account/feature-shell/src/lib/containers/home/home.container.html b/packages/account/feature-shell/src/lib/containers/home/home.container.html
index 1ed093f4..f8b509b1 100644
--- a/packages/account/feature-shell/src/lib/containers/home/home.container.html
+++ b/packages/account/feature-shell/src/lib/containers/home/home.container.html
@@ -1,29 +1,4 @@
-
-
-
-
-
-
-
-
-
+
@@ -60,7 +35,9 @@
} @placeholder {
}
+
+
@defer (on timer(500ms)) {
@@ -73,16 +50,8 @@
}
-
-
- @defer (on timer(500ms)) {
-
- @if (githubFacade.contributors$ | async; as contributors) {
-
- }
-
- } @placeholder {
-
- }
-
+
+@if (githubFacade.contributors$ | async; as contributors) {
+
+}
diff --git a/packages/account/feature-shell/src/lib/containers/home/home.container.ts b/packages/account/feature-shell/src/lib/containers/home/home.container.ts
index ea3bb249..1777dab6 100644
--- a/packages/account/feature-shell/src/lib/containers/home/home.container.ts
+++ b/packages/account/feature-shell/src/lib/containers/home/home.container.ts
@@ -4,7 +4,6 @@ import { PresentationFacade } from '@devmx/presentation-data-access';
import { SkeletonComponent } from '@devmx/shared-ui-global/skeleton';
import { EventCardListComponent } from '@devmx/event-ui-shared';
import { JobOpeningFacade } from '@devmx/career-data-access';
-import { IconComponent } from '@devmx/shared-ui-global/icon';
import { MatButtonModule } from '@angular/material/button';
import { GithubFacade } from '@devmx/shared-data-access';
import { MatCardModule } from '@angular/material/card';
@@ -14,7 +13,7 @@ import { AsyncPipe } from '@angular/common';
import {
AlbumCardListComponent,
JobOpeningCardListComponent,
- ContributorCardListComponent,
+ ContributorsComponent,
} from '../../components';
@Component({
selector: 'devmx-home',
@@ -23,14 +22,13 @@ import {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
PresentationCardListComponent,
- ContributorCardListComponent,
JobOpeningCardListComponent,
EventCardListComponent,
AlbumCardListComponent,
+ ContributorsComponent,
SkeletonComponent,
- MatCardModule,
MatButtonModule,
- IconComponent,
+ MatCardModule,
AsyncPipe,
],
standalone: true,
diff --git a/packages/shared/api-interfaces/src/client/envs/env.ts b/packages/shared/api-interfaces/src/client/envs/env.ts
index ec6f5c03..bbe23566 100644
--- a/packages/shared/api-interfaces/src/client/envs/env.ts
+++ b/packages/shared/api-interfaces/src/client/envs/env.ts
@@ -1,4 +1,6 @@
export abstract class Env {
+ abstract prod: boolean
+
abstract api: {
url: string;
};
@@ -12,4 +14,6 @@ export abstract class Env {
url: string;
};
};
+
+ abstract googleTag: string
}
diff --git a/packages/shared/ui-global/analytics/README.md b/packages/shared/ui-global/analytics/README.md
new file mode 100644
index 00000000..df550b13
--- /dev/null
+++ b/packages/shared/ui-global/analytics/README.md
@@ -0,0 +1,3 @@
+# @devmx/shared-ui-global/analytics
+
+Secondary entry point of `@devmx/shared-ui-global`. It can be used by importing from `@devmx/shared-ui-global/analytics`.
diff --git a/packages/shared/ui-global/analytics/ng-package.json b/packages/shared/ui-global/analytics/ng-package.json
new file mode 100644
index 00000000..c781f0df
--- /dev/null
+++ b/packages/shared/ui-global/analytics/ng-package.json
@@ -0,0 +1,5 @@
+{
+ "lib": {
+ "entryFile": "src/index.ts"
+ }
+}
diff --git a/packages/shared/ui-global/analytics/src/index.ts b/packages/shared/ui-global/analytics/src/index.ts
new file mode 100644
index 00000000..eb504260
--- /dev/null
+++ b/packages/shared/ui-global/analytics/src/index.ts
@@ -0,0 +1,3 @@
+
+export * from './lib/error-report-handler'
+export * from './lib/analytics.service'
diff --git a/packages/shared/ui-global/analytics/src/lib/analytics.service.ts b/packages/shared/ui-global/analytics/src/lib/analytics.service.ts
new file mode 100644
index 00000000..8db84162
--- /dev/null
+++ b/packages/shared/ui-global/analytics/src/lib/analytics.service.ts
@@ -0,0 +1,81 @@
+import { Env } from '@devmx/shared-api-interfaces/client';
+import { formatErrorEventForAnalytics } from './utils';
+import { Injectable } from '@angular/core';
+
+declare global {
+ interface Window {
+ dataLayer?: unknown[];
+ gtag?(...args: unknown[]): void;
+ }
+}
+
+@Injectable({ providedIn: 'root' })
+export class AnalyticsService {
+ private previousUrl: string | undefined;
+
+ constructor(private env: Env) {
+ if (env.prod) {
+ this.#installGlobalSiteTag();
+ this.#installWindowErrorHandler();
+ }
+ }
+
+ reportError(description: string, fatal = true) {
+ // Limit descriptions to maximum of 150 characters.
+ // See: https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#exd.
+ description = description.substring(0, 150);
+
+ this.#gtag('event', 'exception', { description: description, fatal });
+ }
+
+ locationChanged(url: string) {
+ this.#sendPage(url);
+ }
+
+ #sendPage(url: string) {
+ // Won't re-send if the url hasn't changed.
+ if (url === this.previousUrl) {
+ return;
+ }
+ this.previousUrl = url;
+ }
+
+ #gtag(...args: unknown[]) {
+ if (window.gtag) {
+ window.gtag(...args);
+ }
+ }
+
+ #installGlobalSiteTag() {
+ const url = `https://www.googletagmanager.com/gtag/js?id=${this.env.googleTag}`;
+
+ // Note: This cannot be an arrow function as `gtag.js` expects an actual `Arguments`
+ // instance with e.g. `callee` to be set. Do not attempt to change this and keep this
+ // as much as possible in sync with the tracking code snippet suggested by the Google
+ // Analytics 4 web UI under `Data Streams`.
+ window.dataLayer = window.dataLayer || [];
+ window.gtag = function (...params: unknown[]) {
+ window.dataLayer?.push(params);
+ };
+ window.gtag('js', new Date());
+
+ // Configure properties before loading the script. This is necessary to avoid
+ // loading multiple instances of the gtag JS scripts.
+ window.gtag('config', this.env.googleTag);
+
+ if (!this.env.prod) {
+ return;
+ }
+
+ const el = window.document.createElement('script');
+ el.async = true;
+ el.src = url;
+ window.document.head.appendChild(el);
+ }
+
+ #installWindowErrorHandler() {
+ window.addEventListener('error', (event) =>
+ this.reportError(formatErrorEventForAnalytics(event), true)
+ );
+ }
+}
diff --git a/packages/shared/ui-global/analytics/src/lib/error-report-handler.ts b/packages/shared/ui-global/analytics/src/lib/error-report-handler.ts
new file mode 100644
index 00000000..8c257e12
--- /dev/null
+++ b/packages/shared/ui-global/analytics/src/lib/error-report-handler.ts
@@ -0,0 +1,21 @@
+import { ErrorHandler, Injectable } from '@angular/core';
+import { AnalyticsService } from './analytics.service';
+import { formatErrorForAnalytics } from './utils';
+
+@Injectable()
+export class AnalyticsErrorReportHandler extends ErrorHandler {
+ constructor(private _analytics: AnalyticsService) {
+ super();
+ }
+
+ override handleError(error: ErrorHandler) {
+ super.handleError(error);
+
+ // Report the error in Google Analytics.
+ if (error instanceof Error) {
+ this._analytics.reportError(formatErrorForAnalytics(error));
+ } else {
+ this._analytics.reportError(error.toString());
+ }
+ }
+}
diff --git a/packages/shared/ui-global/analytics/src/lib/utils/format-error.ts b/packages/shared/ui-global/analytics/src/lib/utils/format-error.ts
new file mode 100644
index 00000000..3ea80a08
--- /dev/null
+++ b/packages/shared/ui-global/analytics/src/lib/utils/format-error.ts
@@ -0,0 +1,34 @@
+export function formatErrorEventForAnalytics(event: ErrorEvent): string {
+ const { message, filename, colno, lineno, error } = event;
+
+ if (error instanceof Error) {
+ return formatErrorForAnalytics(error);
+ }
+
+ const info = `${filename}:${lineno || '?'}:${colno || '?'}`;
+ return `${stripErrorMessagePrefix(message)} \n ${info}`;
+}
+
+export function formatErrorForAnalytics(error: Error): string {
+ let stack = '';
+
+ if (error.stack) {
+ stack = stripErrorMessagePrefix(error.stack)
+ // strip the message from the stack trace, if present
+ .replace(error.message + '\n', '')
+ // strip leading spaces
+ .replace(/^ +/gm, '')
+ // strip all leading "at " for each frame
+ .replace(/^at /gm, '')
+ // replace long urls with just the last segment: `filename:line:column`
+ .replace(/(?: \(|@)http.+\/([^/)]+)\)?(?:\n|$)/gm, '@$1\n')
+ // replace "eval code" in Edge
+ .replace(/ *\(eval code(:\d+:\d+)\)(?:\n|$)/gm, '@???$1\n');
+ }
+
+ return `${error.message}\n${stack}`;
+}
+
+function stripErrorMessagePrefix(input: string): string {
+ return input.replace(/^(Uncaught )?Error: /, '');
+}
diff --git a/packages/shared/ui-global/analytics/src/lib/utils/index.ts b/packages/shared/ui-global/analytics/src/lib/utils/index.ts
new file mode 100644
index 00000000..e0835d8b
--- /dev/null
+++ b/packages/shared/ui-global/analytics/src/lib/utils/index.ts
@@ -0,0 +1 @@
+export * from './format-error';
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 91076771..91bc0021 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -170,6 +170,9 @@
"@devmx/shared-data-source": ["packages/shared/data-source/src/index.ts"],
"@devmx/shared-resource": ["packages/shared/resource/src/index.ts"],
"@devmx/shared-ui-global": ["packages/shared/ui-global/src/index.ts"],
+ "@devmx/shared-ui-global/analytics": [
+ "packages/shared/ui-global/analytics/src/index.ts"
+ ],
"@devmx/shared-ui-global/bash": [
"packages/shared/ui-global/bash/src/index.ts"
],