Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60b8f1ab58
|
||
|
|
024df873ac
|
||
|
|
a2058927d9
|
||
|
|
a052c846f7
|
||
|
|
376e50357a
|
||
|
|
08f24534b6
|
||
|
|
3581015486
|
No files matched your search
@@ -1,4 +1,4 @@
|
|||||||
name: Build and publish
|
name: build
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -7,49 +7,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
# Required secrets:
|
|
||||||
# REGISTRY git.librete.ch
|
|
||||||
# REGISTRY_USER libretech-bot
|
|
||||||
# REGISTRY_PASS bot PAT (write:package; bot is in libreshop Owners team)
|
|
||||||
# Required variable:
|
|
||||||
# PUBLISH_ENABLED "true" to actually push (off = build-only on PRs)
|
|
||||||
#
|
|
||||||
# Image: git.librete.ch/libreshop/shop
|
|
||||||
# main pushes → :main + :sha-<short>
|
|
||||||
# tag pushes → :<tag> + :latest
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: git.librete.ch/libretech/runner-image:v1
|
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: https://git.librete.ch/public/actions/.gitea/actions/docker-build@main
|
||||||
|
|
||||||
- name: Login (only when publishing)
|
|
||||||
if: ${{ vars.PUBLISH_ENABLED == 'true' }}
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
with:
|
||||||
registry: ${{ secrets.REGISTRY }}
|
registry: ${{ secrets.REGISTRY }}
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
registry_user: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASS }}
|
registry_pass: ${{ secrets.REGISTRY_PASS }}
|
||||||
|
publish: ${{ github.event_name == 'push' && vars.PUBLISH_ENABLED == 'true' }}
|
||||||
- id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: ${{ secrets.REGISTRY }}/libreshop/shop
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
type=sha,format=short
|
|
||||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: ${{ vars.PUBLISH_ENABLED == 'true' && github.event_name == 'push' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
@@ -151,6 +151,23 @@ onMounted(async () => {
|
|||||||
orderTotal: order.value.total,
|
orderTotal: order.value.total,
|
||||||
itemCount: order.value.cart?.length ?? 0
|
itemCount: order.value.cart?.length ?? 0
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Revenue for Umami's revenue report: exactly once per order, even if the
|
||||||
|
// customer reloads this page or opens it again from the confirmation mail.
|
||||||
|
const purchaseKey = `mp-purchase-tracked-${order.value.uuid}`;
|
||||||
|
if (!localStorage.getItem(purchaseKey)) {
|
||||||
|
const products = (order.value.cart ?? []).map((item) => item.product?.slug).filter(Boolean);
|
||||||
|
trackEvent(
|
||||||
|
"purchase-completed",
|
||||||
|
{
|
||||||
|
revenue: order.value.total,
|
||||||
|
currency: "EUR",
|
||||||
|
itemCount: (order.value.cart ?? []).reduce((sum, item) => sum + (item.count ?? 1), 0),
|
||||||
|
products: products.join(",").slice(0, 400)
|
||||||
|
},
|
||||||
|
() => localStorage.setItem(purchaseKey, new Date().toISOString())
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.value?.invoice?.url) {
|
if (order.value?.invoice?.url) {
|
||||||
|
|||||||
+24
-4
@@ -1,15 +1,35 @@
|
|||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
|
const scriptUrl = config.public.umamiScriptUrl as string;
|
||||||
|
const websiteId = config.public.umamiWebsiteId as string;
|
||||||
|
if (!scriptUrl || !websiteId) return;
|
||||||
|
|
||||||
if (config.public.umamiScriptUrl && config.public.umamiWebsiteId) {
|
|
||||||
useHead({
|
useHead({
|
||||||
script: [
|
script: [
|
||||||
{
|
{
|
||||||
src: config.public.umamiScriptUrl,
|
src: scriptUrl,
|
||||||
defer: true,
|
defer: true,
|
||||||
"data-website-id": config.public.umamiWebsiteId
|
"data-website-id": websiteId,
|
||||||
|
"data-do-not-track": "true"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
// Heatmap recorder only on product pages (no personal data there). It needs
|
||||||
|
// heatmapEnabled on the Umami website, set via umami/analytics/reports.json.
|
||||||
|
const recorderUrl = scriptUrl.replace(/script\.js(\?.*)?$/, "recorder.js");
|
||||||
|
let recorderLoaded = false;
|
||||||
|
const loadRecorder = (path: string) => {
|
||||||
|
if (recorderLoaded || !path.startsWith("/details/")) return;
|
||||||
|
if (navigator.doNotTrack === "1") return;
|
||||||
|
recorderLoaded = true;
|
||||||
|
const el = document.createElement("script");
|
||||||
|
el.src = recorderUrl;
|
||||||
|
el.defer = true;
|
||||||
|
el.dataset.websiteId = websiteId;
|
||||||
|
document.head.appendChild(el);
|
||||||
|
};
|
||||||
|
const router = useRouter();
|
||||||
|
loadRecorder(router.currentRoute.value.path);
|
||||||
|
router.afterEach((to) => loadRecorder(to.path));
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { afterEach, beforeEach, expect, test } from "bun:test";
|
||||||
|
|
||||||
|
const sent: Array<[string, unknown]> = [];
|
||||||
|
beforeEach(() => {
|
||||||
|
sent.length = 0;
|
||||||
|
(globalThis as any).window = {};
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
const { __resetTrackQueue } = await import("./trackEvent");
|
||||||
|
__resetTrackQueue();
|
||||||
|
delete (globalThis as any).window;
|
||||||
|
});
|
||||||
|
const umami = { track: (e: string, d: unknown) => sent.push([e, d]) };
|
||||||
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
test("sends immediately when umami is loaded", async () => {
|
||||||
|
const { trackEvent } = await import("./trackEvent");
|
||||||
|
(globalThis as any).window.umami = umami;
|
||||||
|
trackEvent("a", { x: 1 });
|
||||||
|
expect(sent).toEqual([["a", { x: 1 }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("queues until umami appears, then flushes in order and calls onSent", async () => {
|
||||||
|
const { trackEvent } = await import("./trackEvent");
|
||||||
|
let confirmed = false;
|
||||||
|
trackEvent("first");
|
||||||
|
trackEvent("purchase-completed", { revenue: 12.5, currency: "EUR" }, () => (confirmed = true));
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
await wait(300);
|
||||||
|
(globalThis as any).window.umami = umami;
|
||||||
|
await wait(400);
|
||||||
|
expect(sent.map((s) => s[0])).toEqual(["first", "purchase-completed"]);
|
||||||
|
expect(confirmed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never throws and drops events when umami never loads", async () => {
|
||||||
|
const { trackEvent } = await import("./trackEvent");
|
||||||
|
let confirmed = false;
|
||||||
|
trackEvent("lost", undefined, () => (confirmed = true));
|
||||||
|
await wait(10_500);
|
||||||
|
(globalThis as any).window.umami = umami;
|
||||||
|
await wait(400);
|
||||||
|
expect(sent).toEqual([]);
|
||||||
|
expect(confirmed).toBe(false);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
test("is a no-op on the server", async () => {
|
||||||
|
delete (globalThis as any).window;
|
||||||
|
const { trackEvent } = await import("./trackEvent");
|
||||||
|
expect(() => trackEvent("ssr")).not.toThrow();
|
||||||
|
});
|
||||||
+46
-3
@@ -6,8 +6,51 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const trackEvent = (event: string, data?: Record<string, unknown>) => {
|
type Pending = { event: string; data?: Record<string, unknown>; onSent?: () => void };
|
||||||
if (import.meta.client && window.umami) {
|
|
||||||
window.umami.track(event, data);
|
// Events fired during page setup (onMounted) often run before the deferred
|
||||||
|
// Umami script has loaded; they used to be dropped silently. Queue them and
|
||||||
|
// flush once the tracker exists. If it never appears (blocked, offline),
|
||||||
|
// they are discarded after ~10 s without throwing.
|
||||||
|
const queue: Pending[] = [];
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let attempts = 0;
|
||||||
|
const MAX_ATTEMPTS = 40;
|
||||||
|
const INTERVAL_MS = 250;
|
||||||
|
|
||||||
|
function send(item: Pending) {
|
||||||
|
try {
|
||||||
|
window.umami!.track(item.event, item.data);
|
||||||
|
item.onSent?.();
|
||||||
|
} catch {
|
||||||
|
// tracking must never break the shop
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (window.umami) {
|
||||||
|
while (queue.length) send(queue.shift()!);
|
||||||
|
}
|
||||||
|
if (window.umami || ++attempts >= MAX_ATTEMPTS) {
|
||||||
|
queue.length = 0;
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
attempts = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const trackEvent = (event: string, data?: Record<string, unknown>, onSent?: () => void) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const item = { event, data, onSent };
|
||||||
|
if (window.umami) return send(item);
|
||||||
|
queue.push(item);
|
||||||
|
if (!timer) timer = setInterval(flush, INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test hook: reset module state between cases.
|
||||||
|
export const __resetTrackQueue = () => {
|
||||||
|
queue.length = 0;
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
attempts = 0;
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user