@appright/funnelshift 0.0.0-stage → 0.16.0
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/bin/funnel-kit.js +2 -0
- package/dist/anchors.d.ts +4 -0
- package/dist/anchors.js +94 -0
- package/dist/answers.d.ts +11 -0
- package/dist/answers.js +100 -0
- package/dist/attribution.d.ts +34 -0
- package/dist/attribution.js +237 -0
- package/dist/autotrack.d.ts +4 -0
- package/dist/autotrack.js +13 -0
- package/dist/backnav.d.ts +15 -0
- package/dist/backnav.js +73 -0
- package/dist/blocks/Choice.d.ts +19 -0
- package/dist/blocks/Choice.js +38 -0
- package/dist/blocks/Cta.d.ts +7 -0
- package/dist/blocks/Cta.js +8 -0
- package/dist/blocks/Hero.d.ts +10 -0
- package/dist/blocks/Hero.js +48 -0
- package/dist/blocks/MultiChoice.d.ts +13 -0
- package/dist/blocks/MultiChoice.js +47 -0
- package/dist/blocks/Section.d.ts +6 -0
- package/dist/blocks/Section.js +4 -0
- package/dist/bootstrap.d.ts +33 -0
- package/dist/bootstrap.js +19 -0
- package/dist/bucket.d.ts +13 -0
- package/dist/bucket.js +41 -0
- package/dist/cli/args.d.ts +5 -0
- package/dist/cli/args.js +18 -0
- package/dist/cli/build.d.ts +6 -0
- package/dist/cli/build.js +200 -0
- package/dist/cli/extract.d.ts +32 -0
- package/dist/cli/extract.js +253 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +40 -0
- package/dist/cli/init.d.ts +5 -0
- package/dist/cli/init.js +67 -0
- package/dist/cli/lint.d.ts +25 -0
- package/dist/cli/lint.js +535 -0
- package/dist/cli/upload.d.ts +32 -0
- package/dist/cli/upload.js +100 -0
- package/dist/clicks.d.ts +7 -0
- package/dist/clicks.js +102 -0
- package/dist/content.d.ts +7 -0
- package/dist/content.js +60 -0
- package/dist/context.d.ts +12 -0
- package/dist/context.js +52 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +90 -0
- package/dist/experiments.d.ts +4 -0
- package/dist/experiments.js +24 -0
- package/dist/exposure.d.ts +4 -0
- package/dist/exposure.js +35 -0
- package/dist/functions.d.ts +37 -0
- package/dist/functions.js +24 -0
- package/dist/funnel.d.ts +64 -0
- package/dist/funnel.js +433 -0
- package/dist/handoff.d.ts +8 -0
- package/dist/handoff.js +66 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +17 -0
- package/dist/nav.d.ts +19 -0
- package/dist/nav.js +14 -0
- package/dist/offer.d.ts +3 -0
- package/dist/offer.js +7 -0
- package/dist/pixel.d.ts +36 -0
- package/dist/pixel.js +173 -0
- package/dist/prerender.d.ts +2 -0
- package/dist/prerender.js +16 -0
- package/dist/server.d.ts +12 -0
- package/dist/server.js +88 -0
- package/dist/sha256.d.ts +2 -0
- package/dist/sha256.js +65 -0
- package/dist/signals.d.ts +18 -0
- package/dist/signals.js +300 -0
- package/dist/tracker.d.ts +80 -0
- package/dist/tracker.js +446 -0
- package/dist/uuid.d.ts +1 -0
- package/dist/uuid.js +12 -0
- package/dist/vitals.d.ts +4 -0
- package/dist/vitals.js +104 -0
- package/package.json +29 -4
- package/src/blocks/blocks.css +364 -0
- package/templates/init/CLAUDE.md +10 -0
- package/templates/init/funnels/welcome/index.tsx +129 -0
- package/templates/init/gitignore +5 -0
- package/templates/init/package.json.tmpl +20 -0
- package/templates/init/theme.css +77 -0
- package/templates/init/tsconfig.json +7 -0
- package/README.md +0 -3
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/* funnel-kit blocks: Hero, Choice, Cta. Theme through --fk-* variables on :root; every use carries its default
|
|
2
|
+
("appright clean"), so the blocks look right with no theme at all:
|
|
3
|
+
--fk-bg #f7f7f3 --fk-ink #0b0b0b --fk-muted #64645e --fk-accent #d6f84c --fk-radius 16px */
|
|
4
|
+
|
|
5
|
+
/* ---------- Cta ---------- */
|
|
6
|
+
|
|
7
|
+
.fk-cta {
|
|
8
|
+
box-sizing: border-box;
|
|
9
|
+
width: 100%;
|
|
10
|
+
margin-top: auto;
|
|
11
|
+
padding: 12px 0 calc(16px + env(safe-area-inset-bottom, 0px));
|
|
12
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.fk-cta__button {
|
|
16
|
+
position: relative;
|
|
17
|
+
box-sizing: border-box;
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
justify-content: center;
|
|
21
|
+
gap: 12px;
|
|
22
|
+
width: 100%;
|
|
23
|
+
min-height: 58px;
|
|
24
|
+
padding: 16px 28px;
|
|
25
|
+
border: 0;
|
|
26
|
+
border-radius: var(--fk-radius, 16px);
|
|
27
|
+
background: var(--fk-ink, #0b0b0b);
|
|
28
|
+
color: var(--fk-bg, #f7f7f3);
|
|
29
|
+
font: inherit;
|
|
30
|
+
font-size: 18px;
|
|
31
|
+
font-weight: 700;
|
|
32
|
+
line-height: 1.2;
|
|
33
|
+
letter-spacing: -0.01em;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
-webkit-tap-highlight-color: transparent;
|
|
36
|
+
touch-action: manipulation;
|
|
37
|
+
transition: transform 140ms ease-out, box-shadow 180ms ease-out, background-color 180ms ease-out, opacity 180ms ease-out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@media (hover: hover) {
|
|
41
|
+
.fk-cta__button:hover:not(:disabled) {
|
|
42
|
+
transform: translateY(-2px);
|
|
43
|
+
box-shadow: 0 10px 24px -10px color-mix(in srgb, var(--fk-ink, #0b0b0b) 55%, transparent);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.fk-cta__button:focus-visible {
|
|
48
|
+
outline: 3px solid var(--fk-ink, #0b0b0b);
|
|
49
|
+
outline-offset: 4px;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.fk-cta__button:active:not(:disabled) {
|
|
53
|
+
transform: scale(0.98);
|
|
54
|
+
box-shadow: none;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.fk-cta__button:disabled {
|
|
58
|
+
opacity: 0.4;
|
|
59
|
+
cursor: not-allowed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* pending: still the same text, a small ring spins next to it */
|
|
63
|
+
.fk-cta__button[aria-busy="true"] {
|
|
64
|
+
opacity: 1;
|
|
65
|
+
cursor: progress;
|
|
66
|
+
background: color-mix(in srgb, var(--fk-ink, #0b0b0b) 86%, var(--fk-bg, #f7f7f3));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.fk-cta__button[aria-busy="true"]::after {
|
|
70
|
+
content: "";
|
|
71
|
+
flex: none;
|
|
72
|
+
width: 18px;
|
|
73
|
+
height: 18px;
|
|
74
|
+
box-sizing: border-box;
|
|
75
|
+
border: 2.5px solid color-mix(in srgb, var(--fk-bg, #f7f7f3) 30%, transparent);
|
|
76
|
+
border-top-color: var(--fk-accent, #d6f84c);
|
|
77
|
+
border-radius: 50%;
|
|
78
|
+
animation: fk-spin 700ms linear infinite;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@keyframes fk-spin { to { transform: rotate(360deg); } }
|
|
82
|
+
|
|
83
|
+
/* phones: the button is pinned to the bottom of the screen, content fades out underneath it */
|
|
84
|
+
@media (max-width: 639.98px) {
|
|
85
|
+
.fk-cta {
|
|
86
|
+
position: sticky;
|
|
87
|
+
bottom: 0;
|
|
88
|
+
z-index: 2;
|
|
89
|
+
background: linear-gradient(to top, var(--fk-bg, #f7f7f3) 72%, transparent);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
@media (min-width: 640px) {
|
|
94
|
+
.fk-cta { max-width: 360px; margin-inline: auto; padding-bottom: 8px; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* ---------- Hero ---------- */
|
|
98
|
+
|
|
99
|
+
.fk-hero {
|
|
100
|
+
box-sizing: border-box;
|
|
101
|
+
display: flex;
|
|
102
|
+
flex-direction: column;
|
|
103
|
+
min-height: 100dvh;
|
|
104
|
+
max-width: 720px;
|
|
105
|
+
margin: 0 auto;
|
|
106
|
+
padding: 24px 20px 0;
|
|
107
|
+
background: var(--fk-bg, #f7f7f3);
|
|
108
|
+
color: var(--fk-ink, #0b0b0b);
|
|
109
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
110
|
+
text-align: center;
|
|
111
|
+
-webkit-font-smoothing: antialiased;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.fk-hero__main {
|
|
115
|
+
flex: 1;
|
|
116
|
+
display: flex;
|
|
117
|
+
flex-direction: column;
|
|
118
|
+
align-items: center;
|
|
119
|
+
justify-content: center;
|
|
120
|
+
gap: 20px;
|
|
121
|
+
padding-bottom: 6dvh; /* sits a little above the geometric centre */
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.fk-hero__visual {
|
|
125
|
+
display: flex;
|
|
126
|
+
align-items: center;
|
|
127
|
+
justify-content: center;
|
|
128
|
+
margin-bottom: 8px;
|
|
129
|
+
font-size: clamp(96px, 28vw, 120px);
|
|
130
|
+
line-height: 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.fk-hero__visual > img,
|
|
134
|
+
.fk-hero__visual > svg {
|
|
135
|
+
display: block;
|
|
136
|
+
width: auto;
|
|
137
|
+
max-width: 100%;
|
|
138
|
+
height: clamp(96px, 28vw, 120px);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.fk-hero__title {
|
|
142
|
+
margin: 0;
|
|
143
|
+
max-width: 14em;
|
|
144
|
+
font-size: clamp(2.25rem, 1.6rem + 3.2vw, 3.5rem);
|
|
145
|
+
font-weight: 800;
|
|
146
|
+
line-height: 1.05;
|
|
147
|
+
letter-spacing: -0.03em;
|
|
148
|
+
text-wrap: balance;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.fk-hero__subtitle {
|
|
152
|
+
margin: 0;
|
|
153
|
+
max-width: 30ch;
|
|
154
|
+
color: var(--fk-muted, #64645e);
|
|
155
|
+
font-size: clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem);
|
|
156
|
+
line-height: 1.45;
|
|
157
|
+
text-wrap: pretty;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ---------- Brush: the highlight painted by hand in two strokes ----------
|
|
161
|
+
Stroke 1 (::before) sweeps left to right low on the text; stroke 2 (::after) sweeps back right to left a bit
|
|
162
|
+
higher and shorter. 380 ms + 320 ms = 700 ms, ease-out, starting 150 ms after mount. The marker lives behind
|
|
163
|
+
the glyphs (isolation + z-index -1), so the text stays readable at every moment. */
|
|
164
|
+
|
|
165
|
+
.fk-brush {
|
|
166
|
+
position: relative;
|
|
167
|
+
isolation: isolate;
|
|
168
|
+
padding: 0 0.1em;
|
|
169
|
+
background: none;
|
|
170
|
+
color: inherit;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.fk-brush::before,
|
|
174
|
+
.fk-brush::after {
|
|
175
|
+
content: "";
|
|
176
|
+
position: absolute;
|
|
177
|
+
z-index: -1;
|
|
178
|
+
pointer-events: none;
|
|
179
|
+
clip-path: inset(-20% -10% -20% -10%); /* the finished marker; the animation only plays the way to it */
|
|
180
|
+
animation-timing-function: cubic-bezier(0.22, 0.9, 0.3, 1);
|
|
181
|
+
animation-fill-mode: backwards; /* hidden during the start delay; no forwards fill (a held fill lifts the marker above the text) */
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.fk-brush::before {
|
|
185
|
+
left: -0.04em;
|
|
186
|
+
right: -0.06em;
|
|
187
|
+
bottom: 0.03em;
|
|
188
|
+
height: 0.36em;
|
|
189
|
+
background: var(--fk-accent, #d6f84c);
|
|
190
|
+
border-radius: 0.16em 0.34em 0.2em 0.38em / 0.3em 0.18em 0.34em 0.2em;
|
|
191
|
+
transform: skewX(-7deg) rotate(-0.8deg);
|
|
192
|
+
transform-origin: left center;
|
|
193
|
+
animation-name: fk-brush-1;
|
|
194
|
+
animation-duration: 380ms;
|
|
195
|
+
animation-delay: 150ms;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.fk-brush::after {
|
|
199
|
+
left: 0.14em;
|
|
200
|
+
right: 0.02em;
|
|
201
|
+
bottom: 0.25em;
|
|
202
|
+
height: 0.31em;
|
|
203
|
+
background: var(--fk-accent, #d6f84c);
|
|
204
|
+
mix-blend-mode: multiply; /* where the second stroke crosses the first the colour deepens, like a real marker */
|
|
205
|
+
border-radius: 0.32em 0.14em 0.36em 0.18em / 0.2em 0.3em 0.18em 0.32em;
|
|
206
|
+
transform: skewX(-5deg) rotate(0.9deg);
|
|
207
|
+
transform-origin: right center;
|
|
208
|
+
animation-name: fk-brush-2;
|
|
209
|
+
animation-duration: 320ms;
|
|
210
|
+
animation-delay: 530ms; /* 150 + 380 */
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
@keyframes fk-brush-1 { from { clip-path: inset(-20% 110% -20% -10%); } }
|
|
214
|
+
@keyframes fk-brush-2 { from { clip-path: inset(-20% -10% -20% 110%); } }
|
|
215
|
+
|
|
216
|
+
/* A highlight that wraps onto several lines (Hero adds this class): the pseudo strokes cannot follow the lines, so the
|
|
217
|
+
same two strokes are drawn as background layers of every line fragment. */
|
|
218
|
+
.fk-brush--wrap::before,
|
|
219
|
+
.fk-brush--wrap::after { display: none; }
|
|
220
|
+
|
|
221
|
+
.fk-brush--wrap {
|
|
222
|
+
-webkit-box-decoration-break: clone;
|
|
223
|
+
box-decoration-break: clone;
|
|
224
|
+
background-repeat: no-repeat;
|
|
225
|
+
background-image: linear-gradient(var(--fk-accent, #d6f84c), var(--fk-accent, #d6f84c)), linear-gradient(var(--fk-accent, #d6f84c), var(--fk-accent, #d6f84c));
|
|
226
|
+
background-position: left 0 bottom 3%, right 2% bottom 25%;
|
|
227
|
+
background-size: 100% 36%, 84% 31%;
|
|
228
|
+
background-blend-mode: multiply;
|
|
229
|
+
animation: fk-brush-wrap 700ms 150ms backwards;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
@keyframes fk-brush-wrap {
|
|
233
|
+
from { background-size: 0% 36%, 0% 31%; animation-timing-function: cubic-bezier(0.22, 0.9, 0.3, 1); }
|
|
234
|
+
54% { background-size: 100% 36%, 0% 31%; animation-timing-function: cubic-bezier(0.22, 0.9, 0.3, 1); }
|
|
235
|
+
to { background-size: 100% 36%, 84% 31%; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* reduced motion: the finished marker at once. `fk-brush--static` is the same state, set by Hero from matchMedia. */
|
|
239
|
+
.fk-brush--static::before,
|
|
240
|
+
.fk-brush--static::after,
|
|
241
|
+
.fk-brush--wrap.fk-brush--static {
|
|
242
|
+
animation: none;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
@media (prefers-reduced-motion: reduce) {
|
|
246
|
+
.fk-brush::before,
|
|
247
|
+
.fk-brush::after,
|
|
248
|
+
.fk-brush--wrap {
|
|
249
|
+
animation: none;
|
|
250
|
+
}
|
|
251
|
+
.fk-cta__button { transition: none; }
|
|
252
|
+
.fk-cta__button[aria-busy="true"]::after { animation: none; border-top-color: var(--fk-bg, #f7f7f3); }
|
|
253
|
+
.fk-choice__option { transition: none; }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ---------- Shot: one screen drawn as a thumbnail in the admin (?__step=<id>&__shot=1) ----------
|
|
257
|
+
FunnelApp wraps the step in .fk-shot. Nothing moves (the marker is already finished, same as fk-brush--static), and the
|
|
258
|
+
funnel's own progress bar and back button, marked data-fk-chrome (or hidden by useFunnel().shot), are not drawn. */
|
|
259
|
+
|
|
260
|
+
.fk-shot *,
|
|
261
|
+
.fk-shot *::before,
|
|
262
|
+
.fk-shot *::after {
|
|
263
|
+
animation: none !important;
|
|
264
|
+
transition: none !important;
|
|
265
|
+
caret-color: transparent;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.fk-shot [data-fk-chrome] { display: none !important; }
|
|
269
|
+
|
|
270
|
+
/* ---------- Choice ---------- */
|
|
271
|
+
|
|
272
|
+
.fk-choice {
|
|
273
|
+
box-sizing: border-box;
|
|
274
|
+
display: grid;
|
|
275
|
+
gap: 12px;
|
|
276
|
+
width: 100%;
|
|
277
|
+
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.fk-choice__option {
|
|
281
|
+
box-sizing: border-box;
|
|
282
|
+
display: flex;
|
|
283
|
+
align-items: center;
|
|
284
|
+
gap: 16px;
|
|
285
|
+
width: 100%;
|
|
286
|
+
min-height: 60px;
|
|
287
|
+
padding: 12px 18px 12px 16px;
|
|
288
|
+
border: 2px solid color-mix(in srgb, var(--fk-ink, #0b0b0b) 12%, transparent);
|
|
289
|
+
border-radius: var(--fk-radius, 16px);
|
|
290
|
+
background: var(--fk-bg, #f7f7f3);
|
|
291
|
+
color: var(--fk-ink, #0b0b0b);
|
|
292
|
+
font: inherit;
|
|
293
|
+
font-size: 18px;
|
|
294
|
+
font-weight: 600;
|
|
295
|
+
line-height: 1.25;
|
|
296
|
+
text-align: left;
|
|
297
|
+
cursor: pointer;
|
|
298
|
+
-webkit-tap-highlight-color: transparent;
|
|
299
|
+
touch-action: manipulation;
|
|
300
|
+
transition: border-color 160ms ease-out, background-color 160ms ease-out, transform 120ms ease-out, box-shadow 160ms ease-out;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@media (hover: hover) {
|
|
304
|
+
.fk-choice__option:hover {
|
|
305
|
+
border-color: color-mix(in srgb, var(--fk-ink, #0b0b0b) 38%, transparent);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.fk-choice__option:active { transform: scale(0.985); }
|
|
310
|
+
|
|
311
|
+
.fk-choice__option:focus-visible {
|
|
312
|
+
outline: 3px solid var(--fk-ink, #0b0b0b);
|
|
313
|
+
outline-offset: 3px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.fk-choice__option[aria-checked="true"] {
|
|
317
|
+
border-color: color-mix(in srgb, var(--fk-accent, #d6f84c) 55%, var(--fk-ink, #0b0b0b));
|
|
318
|
+
background: color-mix(in srgb, var(--fk-accent, #d6f84c) 38%, var(--fk-bg, #f7f7f3));
|
|
319
|
+
box-shadow: 0 6px 18px -10px color-mix(in srgb, var(--fk-ink, #0b0b0b) 40%, transparent);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.fk-choice__icon {
|
|
323
|
+
flex: none;
|
|
324
|
+
display: flex;
|
|
325
|
+
align-items: center;
|
|
326
|
+
justify-content: center;
|
|
327
|
+
width: 36px;
|
|
328
|
+
font-size: 28px;
|
|
329
|
+
line-height: 1;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.fk-choice__label { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
|
333
|
+
|
|
334
|
+
/* round indicator on the right; a check mark when chosen */
|
|
335
|
+
.fk-choice__mark {
|
|
336
|
+
position: relative;
|
|
337
|
+
flex: none;
|
|
338
|
+
box-sizing: border-box;
|
|
339
|
+
width: 24px;
|
|
340
|
+
height: 24px;
|
|
341
|
+
border: 2px solid color-mix(in srgb, var(--fk-ink, #0b0b0b) 28%, transparent);
|
|
342
|
+
border-radius: 50%;
|
|
343
|
+
transition: background-color 160ms ease-out, border-color 160ms ease-out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/* MultiChoice: a square box, the rows are checkboxes */
|
|
347
|
+
.fk-choice--multi .fk-choice__mark { border-radius: 6px; }
|
|
348
|
+
|
|
349
|
+
.fk-choice__option[aria-checked="true"] .fk-choice__mark {
|
|
350
|
+
border-color: var(--fk-ink, #0b0b0b);
|
|
351
|
+
background: var(--fk-ink, #0b0b0b);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.fk-choice__option[aria-checked="true"] .fk-choice__mark::after {
|
|
355
|
+
content: "";
|
|
356
|
+
position: absolute;
|
|
357
|
+
left: 7px;
|
|
358
|
+
top: 3px;
|
|
359
|
+
width: 5px;
|
|
360
|
+
height: 10px;
|
|
361
|
+
border: solid var(--fk-accent, #d6f84c);
|
|
362
|
+
border-width: 0 2.5px 2.5px 0;
|
|
363
|
+
transform: rotate(45deg);
|
|
364
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Воронки проекта
|
|
2
|
+
|
|
3
|
+
Правила — инструмент `get_guide` MCP-сервера `funnels`. Работай по разделу «Рабочий цикл».
|
|
4
|
+
|
|
5
|
+
Токен проекта только в переменной `FUNNELS_TOKEN`, в файлы его не записывай.
|
|
6
|
+
|
|
7
|
+
События (раздел guide «События»): ответы, нажатия и вызовы функций kit шлёт сам. Ответ записывай `f.set(ключ, значение, текст)` — третьим аргументом текст, который видел человек (`Choice`, `MultiChoice` и поле ввода его не требуют). Кнопке с текстом, собранным на ходу, ставь `data-track="имя"`. Предупреждения `funnel-kit build` (`warning`) читай и исправляй; ложные предупреждения оставь (число в переменной без текста, текст кнопки из `t()` в пропсе) — сборка идёт. После каждой загрузки и прохода по превью вызови `check_funnel` и исправь то, что он нашёл, до `publish`.
|
|
8
|
+
|
|
9
|
+
Серверные функции (по желанию) — в `functions/index.ts` в корне: `export default defineFunctions({...})` из `@appright/funnelshift/functions`, со страницы — `await server('имя', payload)` из `@appright/funnelshift`. `funnel-kit build` сам собирает их в сборку. Бизнес-события и деньги — только из функции: `ctx.track('signup_completed')`.
|
|
10
|
+
Ключи сторонних сервисов — только секреты проекта в сервисе (`set_secret`), в функции они приходят как `ctx.secrets`. В репо и в код их не клади.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { defineFunnel, defineStep, useFunnel, t, Hero, Choice, MultiChoice, Cta } from '@appright/funnelshift'
|
|
2
|
+
|
|
3
|
+
// A five-screen example: intro, one question, a question with several answers, a text field, a closing screen. Replace the
|
|
4
|
+
// texts and the options with your own; every visible text goes through t('key', 'default'), so it can be edited later
|
|
5
|
+
// without a rebuild.
|
|
6
|
+
//
|
|
7
|
+
// Every answer set() on a step is sent with that step, together with the text the person saw (label_<key>): the admin
|
|
8
|
+
// shows "Saving money", not "save". In onChange, Choice and MultiChoice hand the option texts to set() by themselves; in
|
|
9
|
+
// onPick you get the texts and pass them on yourself. A text field needs none (the value is the text); anything else gets
|
|
10
|
+
// it as the third argument: f.set(key, value, label).
|
|
11
|
+
|
|
12
|
+
function Intro() {
|
|
13
|
+
const f = useFunnel()
|
|
14
|
+
return (
|
|
15
|
+
<Hero
|
|
16
|
+
visual={<span aria-hidden="true">👋</span>}
|
|
17
|
+
title={t('intro.title', 'Let’s find what fits you best')}
|
|
18
|
+
highlight={t('intro.highlight', 'fits you best')}
|
|
19
|
+
subtitle={t('intro.subtitle', 'A few quick questions, it takes a minute.')}
|
|
20
|
+
cta={t('intro.cta', 'Start')}
|
|
21
|
+
onCta={() => f.next()}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Goal = 'save' | 'learn' | 'time'
|
|
27
|
+
|
|
28
|
+
// One answer: onChange passes the value to set() as it is, and the option text goes with it.
|
|
29
|
+
function Pick() {
|
|
30
|
+
const f = useFunnel()
|
|
31
|
+
return (
|
|
32
|
+
<main className="screen">
|
|
33
|
+
<section className="screen__body" aria-labelledby="pick-title">
|
|
34
|
+
<h1 id="pick-title">{t('pick.title', 'What matters most to you?')}</h1>
|
|
35
|
+
<Choice
|
|
36
|
+
name="goal"
|
|
37
|
+
labelledBy="pick-title"
|
|
38
|
+
value={f.answers.goal as Goal | undefined}
|
|
39
|
+
onChange={v => f.set('goal', v)}
|
|
40
|
+
options={[
|
|
41
|
+
{ value: 'save', label: t('pick.save', 'Saving money'), icon: '💸' },
|
|
42
|
+
{ value: 'learn', label: t('pick.learn', 'Learning something new'), icon: '📚' },
|
|
43
|
+
{ value: 'time', label: t('pick.time', 'Saving time'), icon: '⏳' },
|
|
44
|
+
]}
|
|
45
|
+
/>
|
|
46
|
+
</section>
|
|
47
|
+
<Cta onClick={() => f.next()} disabled={!f.answers.goal}>{t('pick.cta', 'Continue')}</Cta>
|
|
48
|
+
</main>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Topic = 'tips' | 'news' | 'offers'
|
|
53
|
+
|
|
54
|
+
// Several answers and a Continue button. Here the answer is recorded in onPick, so the texts come from onPick (its second
|
|
55
|
+
// argument) and go to set() as the third argument; onChange={v => f.set('topics', v)} would send the same texts by itself.
|
|
56
|
+
function Topics() {
|
|
57
|
+
const f = useFunnel()
|
|
58
|
+
const topics = (f.answers.topics as Topic[] | undefined) ?? []
|
|
59
|
+
return (
|
|
60
|
+
<main className="screen">
|
|
61
|
+
<section className="screen__body" aria-labelledby="topics-title">
|
|
62
|
+
<h1 id="topics-title">{t('topics.title', 'What would you like to hear about?')}</h1>
|
|
63
|
+
<MultiChoice
|
|
64
|
+
name="topics"
|
|
65
|
+
labelledBy="topics-title"
|
|
66
|
+
value={topics}
|
|
67
|
+
onPick={(values, texts) => f.set('topics', values, texts)}
|
|
68
|
+
options={[
|
|
69
|
+
{ value: 'tips', label: t('topics.tips', 'Practical tips'), icon: '💡' },
|
|
70
|
+
{ value: 'news', label: t('topics.news', 'Product news'), icon: '📰' },
|
|
71
|
+
{ value: 'offers', label: t('topics.offers', 'Special offers'), icon: '🎁' },
|
|
72
|
+
]}
|
|
73
|
+
/>
|
|
74
|
+
</section>
|
|
75
|
+
<Cta onClick={() => f.next()} disabled={!topics.length}>{t('topics.cta', 'Continue')}</Cta>
|
|
76
|
+
</main>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Free text: what the person typed is the answer and its text at once.
|
|
81
|
+
function Name() {
|
|
82
|
+
const f = useFunnel()
|
|
83
|
+
const name = (f.answers.name as string | undefined) ?? ''
|
|
84
|
+
return (
|
|
85
|
+
<main className="screen">
|
|
86
|
+
<section className="screen__body">
|
|
87
|
+
<label className="field">
|
|
88
|
+
<span className="field__label">{t('name.label', 'What should we call you?')}</span>
|
|
89
|
+
<input
|
|
90
|
+
className="field__input"
|
|
91
|
+
name="name"
|
|
92
|
+
autoComplete="given-name"
|
|
93
|
+
maxLength={80}
|
|
94
|
+
value={name}
|
|
95
|
+
placeholder={t('name.placeholder', 'Your first name')}
|
|
96
|
+
onChange={e => f.set('name', e.target.value)}
|
|
97
|
+
/>
|
|
98
|
+
</label>
|
|
99
|
+
</section>
|
|
100
|
+
<Cta onClick={() => f.next()} disabled={!name.trim()}>{t('name.cta', 'Continue')}</Cta>
|
|
101
|
+
</main>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The last step must call next() too: that is what makes the kit send $funnel_complete.
|
|
106
|
+
function Done() {
|
|
107
|
+
const f = useFunnel()
|
|
108
|
+
return (
|
|
109
|
+
<Hero
|
|
110
|
+
visual={<span aria-hidden="true">🎉</span>}
|
|
111
|
+
title={t('done.title', 'Thanks, that is all for now')}
|
|
112
|
+
highlight={t('done.highlight', 'Thanks')}
|
|
113
|
+
subtitle={t('done.subtitle', 'Your answers help us make this better.')}
|
|
114
|
+
cta={t('done.cta', 'Finish')}
|
|
115
|
+
onCta={() => f.next()}
|
|
116
|
+
/>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export default defineFunnel({
|
|
121
|
+
id: 'welcome',
|
|
122
|
+
steps: [
|
|
123
|
+
defineStep({ id: 'intro', component: Intro, sample: {} }),
|
|
124
|
+
defineStep({ id: 'pick', component: Pick, sample: { answers: { goal: 'save' } } }),
|
|
125
|
+
defineStep({ id: 'topics', component: Topics, sample: { answers: { topics: ['tips', 'news'] } } }),
|
|
126
|
+
defineStep({ id: 'name', component: Name, sample: { answers: { name: 'Alex' } } }),
|
|
127
|
+
defineStep({ id: 'done', component: Done, sample: {} }),
|
|
128
|
+
],
|
|
129
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{project}}-funnels",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"typecheck": "tsc --noEmit",
|
|
7
|
+
"build": "funnel-kit build --funnel welcome",
|
|
8
|
+
"upload": "funnel-kit upload --funnel welcome"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@appright/funnelshift": "^{{kit_version}}",
|
|
12
|
+
"react": "^19",
|
|
13
|
+
"react-dom": "^19"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"@types/react": "^19",
|
|
17
|
+
"typescript": "^7",
|
|
18
|
+
"vite": "^8"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/* "appright clean". The blocks bring their own styles; this file overrides the --fk-* variables and lays out the screens. */
|
|
2
|
+
:root {
|
|
3
|
+
--fk-bg: #f7f7f3;
|
|
4
|
+
--fk-ink: #0b0b0b;
|
|
5
|
+
--fk-muted: #64645e;
|
|
6
|
+
--fk-accent: #d6f84c;
|
|
7
|
+
--fk-radius: 16px;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
body {
|
|
11
|
+
margin: 0;
|
|
12
|
+
background: var(--fk-bg);
|
|
13
|
+
color: var(--fk-ink);
|
|
14
|
+
font: 17px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
15
|
+
-webkit-font-smoothing: antialiased;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
h1 {
|
|
19
|
+
margin: 0;
|
|
20
|
+
font-size: clamp(36px, 9vw, 56px);
|
|
21
|
+
line-height: 1.05;
|
|
22
|
+
letter-spacing: -0.03em;
|
|
23
|
+
text-wrap: balance;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* A screen: one centered column, the content in the middle, the Cta at the bottom. */
|
|
27
|
+
.screen {
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
min-height: 100dvh;
|
|
32
|
+
max-width: 480px;
|
|
33
|
+
margin: 0 auto;
|
|
34
|
+
padding: 24px 20px 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.screen__body {
|
|
38
|
+
flex: 1;
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
gap: 28px;
|
|
43
|
+
padding-bottom: 6dvh;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* A text field: the question as a large label, the input as one of the choice rows. */
|
|
47
|
+
.field {
|
|
48
|
+
display: flex;
|
|
49
|
+
flex-direction: column;
|
|
50
|
+
gap: 20px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.field__label {
|
|
54
|
+
font-size: clamp(30px, 7.5vw, 44px);
|
|
55
|
+
font-weight: 700;
|
|
56
|
+
line-height: 1.1;
|
|
57
|
+
letter-spacing: -0.02em;
|
|
58
|
+
text-wrap: balance;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.field__input {
|
|
62
|
+
box-sizing: border-box;
|
|
63
|
+
width: 100%;
|
|
64
|
+
min-height: 56px;
|
|
65
|
+
padding: 14px 18px;
|
|
66
|
+
border: 1.5px solid color-mix(in srgb, var(--fk-ink) 14%, transparent);
|
|
67
|
+
border-radius: var(--fk-radius);
|
|
68
|
+
background: #fff;
|
|
69
|
+
color: var(--fk-ink);
|
|
70
|
+
font: inherit;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.field__input:focus {
|
|
74
|
+
outline: none;
|
|
75
|
+
border-color: var(--fk-ink);
|
|
76
|
+
box-shadow: 0 0 0 4px var(--fk-accent);
|
|
77
|
+
}
|
package/README.md
DELETED