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.
@@ -1,2 +1,2 @@
1
- export declare const CLI_VERSION = "0.1.70";
1
+ export declare const CLI_VERSION = "0.1.72";
2
2
  //# sourceMappingURL=version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.70";
2
+ export const CLI_VERSION = "0.1.72";
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-app",
3
- "version": "0.1.70",
3
+ "version": "0.1.72",
4
4
  "type": "module",
5
5
  "main": "./dist/server/index.js",
6
6
  "exports": {
@@ -1,6 +1,41 @@
1
- import React from 'react';
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
- - mongodb_data:/data/db
13
- - mongodb_config:/data/configdb
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) => {
@@ -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
+ ];