ugly-app 0.1.70 → 0.1.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/package.json +1 -1
- package/templates/client/pages/HomePage.tsx +74 -1
- package/templates/docker-compose.yml +2 -4
- package/templates/server/index.ts +33 -0
- package/templates/shared/api.ts +18 -0
- package/templates/shared/experiments.ts +26 -0
package/dist/cli/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "0.1.
|
|
1
|
+
export declare const CLI_VERSION = "0.1.72";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/cli/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
3
|
import { Button, Card, PageLayout, Text, useApp } from 'ugly-app/client';
|
|
3
4
|
|
|
5
|
+
// ─── Session helpers ──────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function getSessionId(): string {
|
|
8
|
+
const key = 'sessionId';
|
|
9
|
+
const existing = sessionStorage.getItem(key);
|
|
10
|
+
if (existing) return existing;
|
|
11
|
+
const id = nanoid();
|
|
12
|
+
sessionStorage.setItem(key, id);
|
|
13
|
+
return id;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Lightweight HTTP RPC helper for pages that run before socket auth.
|
|
17
|
+
// For authenticated pages with a socket, use socket.request() instead.
|
|
18
|
+
async function rpc<T>(name: string, input: unknown): Promise<T> {
|
|
19
|
+
const res = await fetch(`/api/${name}`, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'Content-Type': 'application/json' },
|
|
22
|
+
body: JSON.stringify({ input }),
|
|
23
|
+
});
|
|
24
|
+
const json = (await res.json()) as { result: T };
|
|
25
|
+
return json.result;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── CTA label per experiment branch ─────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function getCtaLabel(branches: Record<string, string>): string {
|
|
31
|
+
// Add more experiment-driven labels here as you add experiments.
|
|
32
|
+
// 'cta-test' must match the experiment id in shared/experiments.ts.
|
|
33
|
+
if (branches['cta-test'] === 'treatment') return 'Try it free';
|
|
34
|
+
return 'Get started'; // control branch (or default while loading)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Page components ──────────────────────────────────────────────────────────
|
|
38
|
+
|
|
4
39
|
function openLogin(): void {
|
|
5
40
|
window.open(
|
|
6
41
|
`https://ugly.bot/oauth?origin=${encodeURIComponent(
|
|
@@ -72,11 +107,41 @@ function HomePageUnauthenticated(): React.ReactElement {
|
|
|
72
107
|
);
|
|
73
108
|
}
|
|
74
109
|
|
|
110
|
+
// Rendered for both authenticated and unauthenticated users.
|
|
111
|
+
// The experiment CTA is intentionally shown only when userId is null.
|
|
75
112
|
function HomePageBody({
|
|
76
113
|
userId,
|
|
77
114
|
}: {
|
|
78
115
|
userId: string | null;
|
|
79
116
|
}): React.ReactElement {
|
|
117
|
+
const sessionId = useRef(getSessionId());
|
|
118
|
+
const [branches, setBranches] = useState<Record<string, string>>({});
|
|
119
|
+
|
|
120
|
+
// Initialise session: captures SESSION_START and returns experiment branches.
|
|
121
|
+
// Failures are silently swallowed so analytics never block the UI.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
rpc<{ branches: Record<string, string> }>('initSession', {
|
|
124
|
+
sessionId: sessionId.current,
|
|
125
|
+
})
|
|
126
|
+
.then(({ branches: b }) => setBranches(b))
|
|
127
|
+
.catch(() => {
|
|
128
|
+
// Degrade gracefully — UI uses default branch values
|
|
129
|
+
});
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
function handleCtaClick(): void {
|
|
133
|
+
// Fire-and-forget — do not await or block on analytics
|
|
134
|
+
void rpc<{ eventId: string }>('captureEvent', {
|
|
135
|
+
eventName: 'CTA_CLICK',
|
|
136
|
+
sessionId: sessionId.current,
|
|
137
|
+
properties: { page: 'home' },
|
|
138
|
+
}).catch(() => {});
|
|
139
|
+
|
|
140
|
+
openLogin();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const ctaLabel = getCtaLabel(branches);
|
|
144
|
+
|
|
80
145
|
return (
|
|
81
146
|
<div className="max-w-2xl mx-auto px-4 py-6">
|
|
82
147
|
<Card className="mb-6">
|
|
@@ -88,6 +153,14 @@ function HomePageBody({
|
|
|
88
153
|
? `Logged in as: ${userId}`
|
|
89
154
|
: 'This app was built with ugly-app.'}
|
|
90
155
|
</Text>
|
|
156
|
+
{!userId && (
|
|
157
|
+
<div className="mt-4">
|
|
158
|
+
{/* CTA label is experiment-driven — see shared/experiments.ts */}
|
|
159
|
+
<Button variant="primary" onClick={handleCtaClick}>
|
|
160
|
+
{ctaLabel}
|
|
161
|
+
</Button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
91
164
|
</Card>
|
|
92
165
|
|
|
93
166
|
<div className="grid gap-3">
|
|
@@ -9,8 +9,8 @@ services:
|
|
|
9
9
|
ports:
|
|
10
10
|
- '${PORT_MONGO:-3003}:27017'
|
|
11
11
|
volumes:
|
|
12
|
-
-
|
|
13
|
-
-
|
|
12
|
+
- ./local/${COMPOSE_PROJECT_NAME:-uglyapp}/mongodb/data:/data/db
|
|
13
|
+
- ./local/${COMPOSE_PROJECT_NAME:-uglyapp}/mongodb/config:/data/configdb
|
|
14
14
|
healthcheck:
|
|
15
15
|
test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"]
|
|
16
16
|
interval: 5s
|
|
@@ -60,8 +60,6 @@ services:
|
|
|
60
60
|
- redis_data:/data
|
|
61
61
|
|
|
62
62
|
volumes:
|
|
63
|
-
mongodb_data:
|
|
64
|
-
mongodb_config:
|
|
65
63
|
nats_data:
|
|
66
64
|
redis_data:
|
|
67
65
|
minio_data:
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createApp,
|
|
3
3
|
createUserHelper,
|
|
4
|
+
eventLogCapture,
|
|
5
|
+
eventLogServerCapture,
|
|
4
6
|
getFeedbackHandlers,
|
|
7
|
+
getExperimentAssignments,
|
|
5
8
|
type AppConfigurator,
|
|
6
9
|
type RequestHandlers,
|
|
7
10
|
} from 'ugly-app';
|
|
@@ -9,6 +12,7 @@ import { dbDefaults } from 'ugly-app/shared';
|
|
|
9
12
|
import { requests } from '../shared/api';
|
|
10
13
|
import type { User } from '../shared/collections';
|
|
11
14
|
import { collections } from '../shared/collections';
|
|
15
|
+
import { experiments } from '../shared/experiments';
|
|
12
16
|
import { pages } from '../shared/pages';
|
|
13
17
|
|
|
14
18
|
const userHelper = createUserHelper<User>(collections.user);
|
|
@@ -18,6 +22,7 @@ const app = createApp(
|
|
|
18
22
|
{ requests },
|
|
19
23
|
{
|
|
20
24
|
...getFeedbackHandlers(maintainBotUserId),
|
|
25
|
+
|
|
21
26
|
getMe: async (userId: string) => {
|
|
22
27
|
const user = await userHelper.get(app.db, userId);
|
|
23
28
|
return {
|
|
@@ -26,6 +31,34 @@ const app = createApp(
|
|
|
26
31
|
phone: user?.phone,
|
|
27
32
|
};
|
|
28
33
|
},
|
|
34
|
+
|
|
35
|
+
// Compute experiment branch assignments for this user/session and capture
|
|
36
|
+
// SESSION_START so the experiment metrics dashboard can count reach per branch.
|
|
37
|
+
// userId is null for unauthenticated visitors — both are bucketed deterministically.
|
|
38
|
+
initSession: async (userId, { sessionId }) => {
|
|
39
|
+
const branches = getExperimentAssignments(userId, sessionId, experiments);
|
|
40
|
+
await eventLogServerCapture('SESSION_START', {}, sessionId, userId, branches);
|
|
41
|
+
return { branches };
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
// Generic event capture. Re-derives experiment branches so every event is
|
|
45
|
+
// associated with the correct branch in the analytics pipeline.
|
|
46
|
+
// Uses eventLogCapture (not eventLogServerCapture) because this endpoint
|
|
47
|
+
// must return the eventId to the caller.
|
|
48
|
+
captureEvent: async (userId, { eventName, sessionId, properties }) => {
|
|
49
|
+
const branches = getExperimentAssignments(userId, sessionId, experiments);
|
|
50
|
+
const { eventId } = await eventLogCapture(
|
|
51
|
+
{
|
|
52
|
+
eventName,
|
|
53
|
+
sessionId,
|
|
54
|
+
userId,
|
|
55
|
+
properties: properties ?? {},
|
|
56
|
+
experimentBranches: branches,
|
|
57
|
+
},
|
|
58
|
+
userId,
|
|
59
|
+
);
|
|
60
|
+
return { eventId };
|
|
61
|
+
},
|
|
29
62
|
} satisfies RequestHandlers<typeof requests>,
|
|
30
63
|
collections,
|
|
31
64
|
(configurator: AppConfigurator) => {
|
package/templates/shared/api.ts
CHANGED
|
@@ -24,6 +24,24 @@ export const requests = defineRequests({
|
|
|
24
24
|
rateLimit: { max: 20, window: 60 },
|
|
25
25
|
}),
|
|
26
26
|
|
|
27
|
+
// Capture a session start and return experiment branch assignments.
|
|
28
|
+
// Call once on app mount; store the returned branches in React state.
|
|
29
|
+
initSession: req({
|
|
30
|
+
input: z.object({ sessionId: z.string() }),
|
|
31
|
+
output: z.object({ branches: z.record(z.string(), z.string()) }),
|
|
32
|
+
}),
|
|
33
|
+
|
|
34
|
+
// Fire-and-forget event capture. Call whenever a trackable action occurs.
|
|
35
|
+
// Use event names from AppEventName in shared/experiments.ts.
|
|
36
|
+
captureEvent: req({
|
|
37
|
+
input: z.object({
|
|
38
|
+
eventName: z.string(),
|
|
39
|
+
sessionId: z.string(),
|
|
40
|
+
properties: z.record(z.string(), z.string().nullish()).optional(),
|
|
41
|
+
}),
|
|
42
|
+
output: z.object({ eventId: z.string() }),
|
|
43
|
+
}),
|
|
44
|
+
|
|
27
45
|
// Example: public request — userId is string | null
|
|
28
46
|
// getPublicData: req({
|
|
29
47
|
// input: z.object({ id: z.string() }),
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Experiment } from 'ugly-app/shared';
|
|
2
|
+
|
|
3
|
+
// ─── Event names ──────────────────────────────────────────────────────────────
|
|
4
|
+
// Extend this union as you add more trackable events. Use ALL_CAPS convention.
|
|
5
|
+
export type AppEventName = 'SESSION_START' | 'CTA_CLICK';
|
|
6
|
+
|
|
7
|
+
// ─── Experiments ──────────────────────────────────────────────────────────────
|
|
8
|
+
// Define A/B experiments here. Set active: false to pause an experiment.
|
|
9
|
+
// weights are relative — { weight: 1 } on both branches means 50/50 split.
|
|
10
|
+
//
|
|
11
|
+
// After adding an experiment, reference experiments in:
|
|
12
|
+
// - server/index.ts handlers (getExperimentAssignments)
|
|
13
|
+
// - client pages (initSession return value)
|
|
14
|
+
export const experiments: Experiment<AppEventName>[] = [
|
|
15
|
+
{
|
|
16
|
+
id: 'cta-test',
|
|
17
|
+
name: 'CTA Button Copy',
|
|
18
|
+
description: 'Tests two versions of the homepage CTA button label',
|
|
19
|
+
branches: [
|
|
20
|
+
{ id: 'control', name: 'Control', weight: 1 },
|
|
21
|
+
{ id: 'treatment', name: 'Treatment', weight: 1 },
|
|
22
|
+
],
|
|
23
|
+
events: ['SESSION_START', 'CTA_CLICK'],
|
|
24
|
+
active: true,
|
|
25
|
+
},
|
|
26
|
+
];
|