@thewhatmatters/wmds 0.2.0 → 0.4.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/CHANGELOG.md +29 -0
- package/CONSUMING.md +5 -4
- package/README.md +3 -3
- package/dist/components/molecules/PromptBar/PromptBar.d.ts +11 -1
- package/dist/components/molecules/PromptBar/PromptBar.d.ts.map +1 -1
- package/dist/components/molecules/PromptBar/PromptBar.js +54 -43
- package/dist/components/molecules/PromptBar/promptBarStyles.d.ts +15 -0
- package/dist/components/molecules/PromptBar/promptBarStyles.d.ts.map +1 -1
- package/dist/components/molecules/PromptBar/promptBarStyles.js +5 -2
- package/dist/components/organisms/ChatDock/ChatDock.d.ts +93 -0
- package/dist/components/organisms/ChatDock/ChatDock.d.ts.map +1 -0
- package/dist/components/organisms/ChatDock/ChatDock.js +255 -0
- package/dist/components/organisms/ChatDock/chatDockStyles.d.ts +48 -0
- package/dist/components/organisms/ChatDock/chatDockStyles.d.ts.map +1 -0
- package/dist/components/organisms/ChatDock/chatDockStyles.js +15 -0
- package/dist/components/organisms/Dialog/AlertDialog.js +11 -11
- package/dist/components/organisms/Dialog/Dialog.js +44 -44
- package/dist/components/organisms/Sheet/Sheet.js +38 -38
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +30 -28
- package/dist/styles.css +1 -1
- package/docs/component-contracts.md +1 -1
- package/docs/components.md +7 -6
- package/docs/exports.json +14 -14
- package/docs/patterns/components-avatar--dropdown-row.tsx +1 -1
- package/docs/patterns/components-badge--with-avatar.tsx +1 -1
- package/docs/patterns/components-button-button--destructive-action.tsx +1 -1
- package/docs/patterns/components-button-button--ghost-action.tsx +1 -1
- package/docs/patterns/components-button-button--inverse-action.tsx +1 -1
- package/docs/patterns/components-button-button--link-render.tsx +1 -1
- package/docs/patterns/components-button-button--multi-state-badge.tsx +1 -1
- package/docs/patterns/components-button-button--outline-mono.tsx +1 -1
- package/docs/patterns/components-button-button--primary-action.tsx +1 -1
- package/docs/patterns/components-button-button--row-hug.tsx +1 -1
- package/docs/patterns/components-button-button--row-layout.tsx +1 -1
- package/docs/patterns/components-button-button--secondary-action.tsx +1 -1
- package/docs/patterns/components-button-button--suggestion-pills.tsx +1 -1
- package/docs/patterns/components-button-button--with-count.tsx +1 -1
- package/docs/patterns/components-button-button--with-icon.tsx +1 -1
- package/docs/patterns/components-button-floatingactionbutton--vertical-actions.tsx +1 -1
- package/docs/patterns/components-calembed--pattern-skip-in-footer.tsx +1 -1
- package/docs/patterns/components-card-card--body-slot-occupancy-history.tsx +1 -1
- package/docs/patterns/components-card-card--headerless-media.tsx +1 -1
- package/docs/patterns/components-card-card--outlined-layout.tsx +1 -1
- package/docs/patterns/components-card-card--pinned-height.tsx +1 -1
- package/docs/patterns/components-card-selectablecard--selectable-cards-pattern.tsx +1 -1
- package/docs/patterns/components-chart--audience-activity-heatmap.tsx +1 -1
- package/docs/patterns/components-chart--cartesian-no-data-gaps.tsx +1 -1
- package/docs/patterns/components-chart--occupancy-history-in-card.tsx +1 -1
- package/docs/patterns/components-chart--occupancy-in-card.tsx +1 -1
- package/docs/patterns/components-chart--post-reach-distribution.tsx +1 -1
- package/docs/patterns/components-chart--ranked-audience-breakdown.tsx +1 -1
- package/docs/patterns/components-chart--unit-grid-composition.tsx +1 -1
- package/docs/patterns/components-chatdock--pattern-chat-dock.tsx +68 -0
- package/docs/patterns/components-chatqa--pattern-qa-with-reply.tsx +1 -1
- package/docs/patterns/components-checkbox-checkbox--default.tsx +1 -1
- package/docs/patterns/components-checkbox-checkbox--with-description.tsx +1 -1
- package/docs/patterns/components-checkbox-checkboxgroup--default.tsx +1 -1
- package/docs/patterns/components-dialog--alert-dialog-destructive.tsx +1 -1
- package/docs/patterns/components-dialog--default-dialog.tsx +1 -1
- package/docs/patterns/components-dialog--form-dialog.tsx +1 -1
- package/docs/patterns/components-displaycontrols--page-utilities.tsx +1 -1
- package/docs/patterns/components-field--horizontal-input.tsx +1 -1
- package/docs/patterns/components-field--horizontal-text-area.tsx +1 -1
- package/docs/patterns/components-field--multi-control.tsx +1 -1
- package/docs/patterns/components-footerreveal--marketing-hero-pattern.tsx +1 -1
- package/docs/patterns/components-footerreveal--marketing-hero-ruled-pattern.tsx +1 -1
- package/docs/patterns/components-footerreveal--marketing-hero-with-gallery-intro.tsx +1 -1
- package/docs/patterns/components-footerreveal--marketing-page-pattern.tsx +1 -1
- package/docs/patterns/components-footerreveal--ruled-grid-footer-pattern.tsx +1 -1
- package/docs/patterns/components-footerreveal--sequenced-marketing-hero.tsx +1 -1
- package/docs/patterns/components-herointro--two-line-intro.tsx +1 -1
- package/docs/patterns/components-herotilestack--marketing-hero-pattern.tsx +1 -1
- package/docs/patterns/components-herotilestack--marketing-hero-text-sequence.tsx +1 -1
- package/docs/patterns/components-herotilestack--marketing-hero-with-gallery-intro.tsx +1 -1
- package/docs/patterns/components-intakeconfirmation--booked-pattern.tsx +1 -1
- package/docs/patterns/components-intakeconfirmation--emailed-pattern.tsx +1 -1
- package/docs/patterns/components-intakeform--about-you-pattern.tsx +1 -1
- package/docs/patterns/components-intakemodal--intake-modal-pattern.tsx +1 -1
- package/docs/patterns/components-kbd--command-row.tsx +1 -1
- package/docs/patterns/components-kbd--numbered-choices.tsx +1 -1
- package/docs/patterns/components-kbd--shortcut.tsx +1 -1
- package/docs/patterns/components-moremenu--action-menu.tsx +1 -1
- package/docs/patterns/components-moremenu--in-card-header.tsx +1 -1
- package/docs/patterns/components-navlist--side-nav-settings.tsx +1 -1
- package/docs/patterns/components-pageheader--app-header-pattern.tsx +1 -1
- package/docs/patterns/components-pageheader--page-section-header.tsx +1 -1
- package/docs/patterns/components-pageheader--toolbar-header.tsx +1 -1
- package/docs/patterns/components-panel--end-detail-rail.tsx +1 -1
- package/docs/patterns/components-pillgroup--pill-group-pattern.tsx +1 -1
- package/docs/patterns/components-promptbar--pattern-prompt-bar.tsx +1 -1
- package/docs/patterns/components-radio-radiogroup--default.tsx +1 -1
- package/docs/patterns/components-scrollhorizontal--project-gallery-pattern.tsx +1 -1
- package/docs/patterns/components-scrollhorizontal--with-intro.tsx +1 -1
- package/docs/patterns/components-sheet--bottom-sheet.tsx +1 -1
- package/docs/patterns/components-sheet--end-sheet.tsx +1 -1
- package/docs/patterns/components-sitenav--brand-and-cta.tsx +1 -1
- package/docs/patterns/components-sitenav--centered-links.tsx +1 -1
- package/docs/patterns/components-sitenav--compact.tsx +1 -1
- package/docs/patterns/components-sitenav--marketing-header.tsx +1 -1
- package/docs/patterns/components-sitenav--mega-menu.tsx +1 -1
- package/docs/patterns/components-stat--insights-row.tsx +1 -1
- package/docs/patterns/components-stepprogress--step-progress-pattern.tsx +1 -1
- package/docs/patterns/components-switch--default.tsx +1 -1
- package/docs/patterns/components-switch--settings-row.tsx +1 -1
- package/docs/patterns/components-tab--settings-pages.tsx +1 -1
- package/docs/patterns/components-taskrows--action-details.tsx +1 -1
- package/docs/patterns/components-taskrows--capsules.tsx +1 -1
- package/docs/patterns/components-taskrows--card-with-status-rows.tsx +1 -1
- package/docs/patterns/components-taskrows--controlled-expand.tsx +1 -1
- package/docs/patterns/components-taskrows--custom-slots.tsx +1 -1
- package/docs/patterns/components-taskrows--status-list.tsx +1 -1
- package/docs/patterns/components-taskrows--tag-chips.tsx +1 -1
- package/docs/patterns/components-textlink--external-destination.tsx +1 -1
- package/docs/patterns/components-textlink--inline-prose.tsx +1 -1
- package/docs/patterns/components-textlink--supporting-copy.tsx +1 -1
- package/docs/patterns/components-textsequence--default.tsx +1 -1
- package/docs/patterns/components-textsequence--reduced-motion.tsx +1 -1
- package/docs/patterns/components-textsequence--shapes.tsx +1 -1
- package/docs/patterns/components-toast--stacked-notifications.tsx +1 -1
- package/docs/patterns/components-tooltip--controlled.tsx +1 -1
- package/docs/patterns/components-tooltip--disabled-control.tsx +1 -1
- package/docs/patterns/components-tooltip--icon-action.tsx +1 -1
- package/docs/patterns/components-tooltip--keyboard-shortcut.tsx +1 -1
- package/docs/patterns/components-tooltip--placement.tsx +1 -1
- package/docs/patterns/foundations-scroll-fade--horizontal-rail.tsx +1 -1
- package/docs/patterns/foundations-scroll-fade--overflowing-list.tsx +1 -1
- package/docs/patterns/guides-chart-explorations--comparison-gallery.tsx +1 -1
- package/docs/patterns/guides-overlay-flows--notification-preferences.tsx +1 -1
- package/docs/patterns/guides-profile-typography--editorial-profile.tsx +1 -1
- package/docs/patterns/index.json +9 -161
- package/package.json +1 -1
- package/docs/patterns/components-promptbar--pattern-marketing-composer.tsx +0 -37
- package/docs/patterns/sites-pitchkit-account-settings--account-settings-owner.tsx +0 -265
- package/docs/patterns/sites-pitchkit-creator-identity--creator-identity-owner-settings.tsx +0 -154
- package/docs/patterns/sites-pitchkit-creator-identity--creator-identity-public.tsx +0 -96
- package/docs/patterns/sites-pitchkit-insights-and-kit--creator-insights-loading.tsx +0 -1006
- package/docs/patterns/sites-pitchkit-insights-and-kit--creator-insights.tsx +0 -1031
- package/docs/patterns/sites-pitchkit-insights-and-kit--insufficient-audience-data.tsx +0 -1015
- package/docs/patterns/sites-pitchkit-insights-and-kit--insufficient-reach-and-audience-data.tsx +0 -1017
- package/docs/patterns/sites-pitchkit-insights-and-kit--insufficient-reach-data.tsx +0 -1033
- package/docs/patterns/sites-pitchkit-insights-and-kit--owner-pitch-kit.tsx +0 -838
- package/docs/patterns/sites-pitchkit-insights-and-kit--shareable-pitch-kit.tsx +0 -608
- package/docs/patterns/sites-pitchkit-intro--intro-owner.tsx +0 -161
- package/docs/patterns/sites-pitchkit-intro--intro-public.tsx +0 -111
- package/docs/patterns/sites-pitchkit-past-brands--past-brands-owner.tsx +0 -611
- package/docs/patterns/sites-pitchkit-past-brands--past-brands-public.tsx +0 -299
- package/docs/patterns/sites-pitchkit-theme-picker--theme-picker-owner.tsx +0 -832
- package/docs/patterns/sites-whatmatters-intake--start-a-project-pattern.tsx +0 -234
- package/docs/patterns/sites-whatmatters-marketing-landing--marketing-landing.tsx +0 -87
- package/docs/patterns/sites-whatmatters-prompt-chat--landing-to-chat.tsx +0 -899
- package/docs/patterns/sites-whatmatters-rfp-submitted--rfp-submitted.tsx +0 -302
|
@@ -1,899 +0,0 @@
|
|
|
1
|
-
// @thewhatmatters/wmds@0.2.0 · Pattern — landing to chat
|
|
2
|
-
// Storybook: Sites/WhatMatters/Prompt chat → Pattern — landing to chat (?path=/story/sites-whatmatters-prompt-chat--landing-to-chat)
|
|
3
|
-
// Show code — copy verbatim and keep this header; upgrades find pasted patterns by it.
|
|
4
|
-
|
|
5
|
-
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent } from "react";
|
|
6
|
-
import { Check, ChevronDown, ChevronLeft, ChevronRight, Copy, LoaderCircle, Sparkle, Sparkles, ThumbsDown, ThumbsUp, X } from "lucide-react";
|
|
7
|
-
import { motion, useReducedMotion, type Transition } from "motion/react";
|
|
8
|
-
import {
|
|
9
|
-
Button,
|
|
10
|
-
CalEmbed,
|
|
11
|
-
Card,
|
|
12
|
-
cardLayoutBodyOccupantInsetXClasses,
|
|
13
|
-
cardLayoutBodyOccupantWellClasses,
|
|
14
|
-
cardSubtitleClasses,
|
|
15
|
-
cardTitleClasses,
|
|
16
|
-
ChatQa,
|
|
17
|
-
Checkbox,
|
|
18
|
-
ConfettiProvider,
|
|
19
|
-
IconButton,
|
|
20
|
-
IntakeConfirmation,
|
|
21
|
-
IntakeForm,
|
|
22
|
-
intakeAboutEmpty,
|
|
23
|
-
Kbd,
|
|
24
|
-
PillGroup,
|
|
25
|
-
PromptBar,
|
|
26
|
-
SiteNav,
|
|
27
|
-
TextLink,
|
|
28
|
-
motionBeatSeconds,
|
|
29
|
-
motionBlurReveal,
|
|
30
|
-
motionStaggerSeconds,
|
|
31
|
-
motionTransitionProp,
|
|
32
|
-
readMotionDurationSeconds,
|
|
33
|
-
useKbdChoiceKeys,
|
|
34
|
-
type ChatQaPair,
|
|
35
|
-
type IntakeAboutValues,
|
|
36
|
-
intakeDetailsMax,
|
|
37
|
-
isIntakeAboutValid,
|
|
38
|
-
} from "@thewhatmatters/wmds";
|
|
39
|
-
|
|
40
|
-
const pageClasses = "flex h-[100svh] min-h-0 w-full flex-col overflow-hidden bg-body";
|
|
41
|
-
const columnClasses = "flex min-h-0 w-full flex-1 flex-col overflow-hidden [--grid-max:40rem]";
|
|
42
|
-
const stageClasses = "grid-page min-h-0 w-full flex-1 overflow-y-auto !py-0";
|
|
43
|
-
const landingClasses = "place-content-center";
|
|
44
|
-
const headlineClasses =
|
|
45
|
-
"col-span-full type-display-2 !font-normal text-balance text-center text-brand";
|
|
46
|
-
const threadClasses = "col-span-full flex w-full min-w-0 flex-col items-start gap-6 pt-6";
|
|
47
|
-
const userClasses =
|
|
48
|
-
"ml-auto max-w-full rounded-full bg-fill-selected px-4 py-2 type-body text-fg";
|
|
49
|
-
const replyClasses = "w-full type-body text-fg";
|
|
50
|
-
const replyBlockClasses =
|
|
51
|
-
"group/reply w-full outline-none [&[data-actions=open]]:outline-none";
|
|
52
|
-
const traceClasses = "flex w-full flex-col items-start gap-2";
|
|
53
|
-
const traceBodyClasses =
|
|
54
|
-
"flex w-full flex-col items-start gap-2 border-l border-border-control py-0.5 pl-3";
|
|
55
|
-
const traceLineClasses = "flex items-center gap-2 type-supporting text-muted";
|
|
56
|
-
const thoughtLabelClasses = "type-supporting text-muted";
|
|
57
|
-
const traceIconClasses = "size-4 shrink-0 text-muted";
|
|
58
|
-
const traceSpinClasses = "size-4 shrink-0 animate-spin text-muted";
|
|
59
|
-
const traceChevronClasses = "size-4 shrink-0 text-muted";
|
|
60
|
-
const actionsClasses =
|
|
61
|
-
"flex items-center gap-1 opacity-0 pointer-events-none transition-opacity duration-fast ease-standard group-hover/reply:opacity-100 group-hover/reply:pointer-events-auto group-focus-within/reply:opacity-100 group-focus-within/reply:pointer-events-auto group-data-[actions=open]/reply:opacity-100 group-data-[actions=open]/reply:pointer-events-auto";
|
|
62
|
-
const followUpsClasses = "flex w-full flex-col gap-2";
|
|
63
|
-
const followUpClasses = "w-full";
|
|
64
|
-
const barClasses = "grid-page w-full shrink-0 !pt-0 !pb-6";
|
|
65
|
-
const composerClasses = "col-span-full";
|
|
66
|
-
const startGateCardClasses = "h-[411px]";
|
|
67
|
-
const startGateOccupantClasses = "flex min-h-full w-full flex-col gap-3 py-3";
|
|
68
|
-
const startGateStepClasses = "type-supporting text-muted px-1 tabular-nums";
|
|
69
|
-
const startGateOptionsClasses = "flex w-full flex-col";
|
|
70
|
-
const startGateOptionClasses =
|
|
71
|
-
"flex w-full items-center gap-3 border-b border-border py-3 last:border-b-0";
|
|
72
|
-
const startGateOptionNumberClasses = "shrink-0";
|
|
73
|
-
const startOptions = [
|
|
74
|
-
{ value: "brand", label: "Brand identity", description: "Name, mark, and a system you can actually use.", number: 1 },
|
|
75
|
-
{ value: "website", label: "Website", description: "A site that explains the work and earns the next conversation.", number: 2 },
|
|
76
|
-
{ value: "product", label: "Product design", description: "Flows, screens, and the details in between.", number: 3 },
|
|
77
|
-
{ value: "system", label: "Design system", description: "Components, tokens, and the rules that keep them honest.", number: 4 },
|
|
78
|
-
] as const;
|
|
79
|
-
const startBudgets = [
|
|
80
|
-
{ value: "under-10", label: "<$10k", emphasis: "solid" },
|
|
81
|
-
{ value: "10-25", label: "$10–25k", emphasis: "solid" },
|
|
82
|
-
{ value: "25-50", label: "$25–50k", emphasis: "solid" },
|
|
83
|
-
{ value: "50-plus", label: "$50k+", emphasis: "solid" },
|
|
84
|
-
{ value: "unsure", label: "Not sure yet", emphasis: "muted" },
|
|
85
|
-
] as const;
|
|
86
|
-
type IntakeStep = 1 | 2 | 3 | 4;
|
|
87
|
-
type IntakePhase =
|
|
88
|
-
| { kind: "step"; step: IntakeStep }
|
|
89
|
-
| { kind: "done"; variant: "booked" | "emailed" };
|
|
90
|
-
const startGateCopy: Record<IntakeStep, { title: string; subtitle: string }> = {
|
|
91
|
-
1: { title: "Name the work", subtitle: "What are we making?" },
|
|
92
|
-
2: { title: "What's the budget?", subtitle: "A range is enough. We can tighten it after the first conversation." },
|
|
93
|
-
3: { title: "About you", subtitle: "A few sentences is enough. We'll reply to the email you leave here." },
|
|
94
|
-
4: { title: "Book a call", subtitle: "Pick a time, or skip this and we'll write to you instead." },
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
function canContinueStart(
|
|
98
|
-
step: IntakeStep,
|
|
99
|
-
needs: readonly string[],
|
|
100
|
-
budget: string | null,
|
|
101
|
-
about: IntakeAboutValues,
|
|
102
|
-
) {
|
|
103
|
-
if (step === 1) return needs.length > 0;
|
|
104
|
-
if (step === 2) return budget != null;
|
|
105
|
-
if (step === 3) return isIntakeAboutValid(about, intakeDetailsMax);
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const headline = "What should we make?";
|
|
110
|
-
const sampleReply =
|
|
111
|
-
"Brand, product, and the sites that explain them. The studio notes cover how a project starts.";
|
|
112
|
-
type ReplyPart = { kind: "word"; text: string } | { kind: "source"; text: string; href: string };
|
|
113
|
-
const replyParts: ReplyPart[] = [
|
|
114
|
-
{ kind: "word", text: "Brand," },
|
|
115
|
-
{ kind: "word", text: "product," },
|
|
116
|
-
{ kind: "word", text: "and" },
|
|
117
|
-
{ kind: "word", text: "the" },
|
|
118
|
-
{ kind: "word", text: "sites" },
|
|
119
|
-
{ kind: "word", text: "that" },
|
|
120
|
-
{ kind: "word", text: "explain" },
|
|
121
|
-
{ kind: "word", text: "them." },
|
|
122
|
-
{ kind: "word", text: "The" },
|
|
123
|
-
{ kind: "source", text: "studio notes", href: "/notes" },
|
|
124
|
-
{ kind: "word", text: "cover" },
|
|
125
|
-
{ kind: "word", text: "how" },
|
|
126
|
-
{ kind: "word", text: "a" },
|
|
127
|
-
{ kind: "word", text: "project" },
|
|
128
|
-
{ kind: "word", text: "starts." },
|
|
129
|
-
];
|
|
130
|
-
const followUps = [
|
|
131
|
-
"What does a brand engagement include?",
|
|
132
|
-
"How do you start a product design?",
|
|
133
|
-
];
|
|
134
|
-
function thoughtForLabel(seconds: number) {
|
|
135
|
-
const n = Math.max(1, Math.floor(seconds));
|
|
136
|
-
return n === 1 ? "Thought for 1 second" : "Thought for " + n + " seconds";
|
|
137
|
-
}
|
|
138
|
-
// Trace choreography in --motion-beat units: a step every 3 beats, the spinner for 2, a 13-beat hold.
|
|
139
|
-
const traceBeats = { step: 3, spin: 2, hold: 13 };
|
|
140
|
-
const traceSteps = ["Read the brief", "Name the brand", "Check the product site", "Find the studio notes"];
|
|
141
|
-
type Point = { top: number; left: number };
|
|
142
|
-
|
|
143
|
-
function partDelay(index: number, reduce: boolean, settleSeconds: number, staggerSeconds: number) {
|
|
144
|
-
if (reduce || index <= 0) return 0;
|
|
145
|
-
let time = 0;
|
|
146
|
-
for (let i = 0; i < index; i += 1) {
|
|
147
|
-
const part = replyParts[i];
|
|
148
|
-
const next = replyParts[i + 1];
|
|
149
|
-
const hold = part?.kind === "source" || next?.kind === "source";
|
|
150
|
-
time += hold ? settleSeconds : staggerSeconds;
|
|
151
|
-
}
|
|
152
|
-
return time;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function intakeQaPairs(
|
|
156
|
-
needs: readonly string[],
|
|
157
|
-
budget: string | null,
|
|
158
|
-
about: IntakeAboutValues,
|
|
159
|
-
outcome: "booked" | "emailed",
|
|
160
|
-
): ChatQaPair[] {
|
|
161
|
-
const needsAnswer = startOptions
|
|
162
|
-
.filter((option) => needs.includes(option.value))
|
|
163
|
-
.map((option) => option.label)
|
|
164
|
-
.join(", ");
|
|
165
|
-
const budgetAnswer = startBudgets.find((option) => option.value === budget)?.label ?? "";
|
|
166
|
-
const aboutAnswer = [about.name.trim(), about.company.trim()].filter((part) => part.length > 0).join(", ");
|
|
167
|
-
const followUpAnswer = outcome === "booked" ? "Booked a call" : "Email me";
|
|
168
|
-
return [
|
|
169
|
-
{ question: "What are we making?", answer: needsAnswer },
|
|
170
|
-
{ question: "What's the budget?", answer: budgetAnswer },
|
|
171
|
-
{ question: "About you", answer: aboutAnswer },
|
|
172
|
-
{ question: "How should we follow up?", answer: followUpAnswer },
|
|
173
|
-
].filter((pair) => pair.answer.length > 0);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
type Turn =
|
|
177
|
-
| { id: number; kind: "prompt"; prompt: string }
|
|
178
|
-
| { id: number; kind: "intake"; pairs: ChatQaPair[] };
|
|
179
|
-
|
|
180
|
-
function IntakeExchange({
|
|
181
|
-
pairs,
|
|
182
|
-
latest,
|
|
183
|
-
reduce,
|
|
184
|
-
onFollowUp,
|
|
185
|
-
onReplyGrown,
|
|
186
|
-
}: {
|
|
187
|
-
pairs: readonly ChatQaPair[];
|
|
188
|
-
latest: boolean;
|
|
189
|
-
reduce: boolean;
|
|
190
|
-
onFollowUp: (value: string) => void;
|
|
191
|
-
onReplyGrown: () => void;
|
|
192
|
-
}) {
|
|
193
|
-
const fast = motionTransitionProp("fast");
|
|
194
|
-
const [mark, setMark] = useState<"up" | "down" | null>(null);
|
|
195
|
-
const [streamDone, setStreamDone] = useState(reduce);
|
|
196
|
-
const [actionsOpen, setActionsOpen] = useState(false);
|
|
197
|
-
const settleSeconds = readMotionDurationSeconds("fast");
|
|
198
|
-
const staggerSeconds = motionStaggerSeconds();
|
|
199
|
-
|
|
200
|
-
useEffect(() => {
|
|
201
|
-
if (!actionsOpen) return;
|
|
202
|
-
const onPointerDown = (event: PointerEvent) => {
|
|
203
|
-
const target = event.target;
|
|
204
|
-
if (!(target instanceof Node)) return;
|
|
205
|
-
const block = (target as Element).closest?.("[data-reply-actions]");
|
|
206
|
-
if (block != null) return;
|
|
207
|
-
setActionsOpen(false);
|
|
208
|
-
};
|
|
209
|
-
window.addEventListener("pointerdown", onPointerDown);
|
|
210
|
-
return () => window.removeEventListener("pointerdown", onPointerDown);
|
|
211
|
-
}, [actionsOpen]);
|
|
212
|
-
|
|
213
|
-
useEffect(() => {
|
|
214
|
-
if (streamDone) return;
|
|
215
|
-
const last = replyParts.length - 1;
|
|
216
|
-
const wait = (partDelay(last, reduce, settleSeconds, staggerSeconds) + settleSeconds) * 1000;
|
|
217
|
-
const timer = window.setTimeout(() => setStreamDone(true), wait);
|
|
218
|
-
return () => window.clearTimeout(timer);
|
|
219
|
-
}, [reduce, streamDone, settleSeconds, staggerSeconds]);
|
|
220
|
-
|
|
221
|
-
useLayoutEffect(() => {
|
|
222
|
-
onReplyGrown();
|
|
223
|
-
}, [streamDone, onReplyGrown]);
|
|
224
|
-
|
|
225
|
-
function copyReply() {
|
|
226
|
-
if (typeof navigator === "undefined" || navigator.clipboard == null) return;
|
|
227
|
-
void navigator.clipboard.writeText(sampleReply).catch(() => undefined);
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
return (
|
|
231
|
-
<>
|
|
232
|
-
<ChatQa pairs={pairs} />
|
|
233
|
-
<div
|
|
234
|
-
className={replyBlockClasses}
|
|
235
|
-
data-reply-actions=""
|
|
236
|
-
data-actions={actionsOpen ? "open" : undefined}
|
|
237
|
-
onClick={() => {
|
|
238
|
-
if (typeof window === "undefined") return;
|
|
239
|
-
if (!window.matchMedia("(hover: none)").matches) return;
|
|
240
|
-
setActionsOpen(true);
|
|
241
|
-
}}
|
|
242
|
-
>
|
|
243
|
-
<p className={replyClasses} aria-busy={streamDone ? undefined : true}>
|
|
244
|
-
{replyParts.map((part, index) => (
|
|
245
|
-
<ReplyWord
|
|
246
|
-
key={part.kind + "-" + index}
|
|
247
|
-
part={part}
|
|
248
|
-
index={index}
|
|
249
|
-
reduce={reduce}
|
|
250
|
-
delay={partDelay(index, reduce, settleSeconds, staggerSeconds)}
|
|
251
|
-
settle={fast}
|
|
252
|
-
onDone={index === replyParts.length - 1 ? () => setStreamDone(true) : undefined}
|
|
253
|
-
/>
|
|
254
|
-
))}
|
|
255
|
-
</p>
|
|
256
|
-
{streamDone ? (
|
|
257
|
-
<div className={actionsClasses}>
|
|
258
|
-
<IconButton aria-label="Copy reply" size="sm" icon={<Copy />} onClick={copyReply} />
|
|
259
|
-
<IconButton
|
|
260
|
-
aria-label="Mark this reply helpful"
|
|
261
|
-
size="sm"
|
|
262
|
-
icon={<ThumbsUp />}
|
|
263
|
-
aria-pressed={mark === "up"}
|
|
264
|
-
onClick={() => setMark((current) => (current === "up" ? null : "up"))}
|
|
265
|
-
/>
|
|
266
|
-
<IconButton
|
|
267
|
-
aria-label="Mark this reply not helpful"
|
|
268
|
-
size="sm"
|
|
269
|
-
icon={<ThumbsDown />}
|
|
270
|
-
aria-pressed={mark === "down"}
|
|
271
|
-
onClick={() => setMark((current) => (current === "down" ? null : "down"))}
|
|
272
|
-
/>
|
|
273
|
-
</div>
|
|
274
|
-
) : null}
|
|
275
|
-
</div>
|
|
276
|
-
{streamDone && latest ? (
|
|
277
|
-
<div className={followUpsClasses}>
|
|
278
|
-
{followUps.map((next) => (
|
|
279
|
-
<Button key={next} role="outline" emphasis="quiet" align="start" size="md" type="button" className={followUpClasses} onClick={() => onFollowUp(next)}>
|
|
280
|
-
{next}
|
|
281
|
-
</Button>
|
|
282
|
-
))}
|
|
283
|
-
</div>
|
|
284
|
-
) : null}
|
|
285
|
-
</>
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function Exchange({
|
|
290
|
-
prompt,
|
|
291
|
-
arrived = false,
|
|
292
|
-
latest,
|
|
293
|
-
reduce,
|
|
294
|
-
onFollowUp,
|
|
295
|
-
onReplyGrown,
|
|
296
|
-
}: {
|
|
297
|
-
prompt: string;
|
|
298
|
-
/** The prompt came with the page (`initialPrompt`): it is already there, so it does not fade in. */
|
|
299
|
-
arrived?: boolean;
|
|
300
|
-
latest: boolean;
|
|
301
|
-
reduce: boolean;
|
|
302
|
-
onFollowUp: (value: string) => void;
|
|
303
|
-
onReplyGrown: () => void;
|
|
304
|
-
}) {
|
|
305
|
-
const fast = motionTransitionProp("fast");
|
|
306
|
-
const [mark, setMark] = useState<"up" | "down" | null>(null);
|
|
307
|
-
const [revealed, setRevealed] = useState(reduce ? traceSteps.length : 0);
|
|
308
|
-
const [resolved, setResolved] = useState(reduce ? traceSteps.length : 0);
|
|
309
|
-
const [traceOpen, setTraceOpen] = useState(false);
|
|
310
|
-
const [replyReady, setReplyReady] = useState(reduce);
|
|
311
|
-
const [streamDone, setStreamDone] = useState(reduce);
|
|
312
|
-
const [actionsOpen, setActionsOpen] = useState(false);
|
|
313
|
-
const [thoughtSeconds, setThoughtSeconds] = useState(1);
|
|
314
|
-
|
|
315
|
-
useEffect(() => {
|
|
316
|
-
if (reduce || replyReady) return;
|
|
317
|
-
const timer = window.setInterval(() => setThoughtSeconds((current) => current + 1), 1000);
|
|
318
|
-
return () => window.clearInterval(timer);
|
|
319
|
-
}, [reduce, replyReady]);
|
|
320
|
-
|
|
321
|
-
useEffect(() => {
|
|
322
|
-
if (reduce) return;
|
|
323
|
-
const beatMs = motionBeatSeconds(traceBeats.step) * 1000;
|
|
324
|
-
const spinMs = motionBeatSeconds(traceBeats.spin) * 1000;
|
|
325
|
-
const timers: number[] = [];
|
|
326
|
-
for (let index = 1; index <= traceSteps.length; index += 1) {
|
|
327
|
-
timers.push(window.setTimeout(() => setRevealed(index), index * beatMs));
|
|
328
|
-
timers.push(window.setTimeout(() => setResolved(index), index * beatMs + spinMs));
|
|
329
|
-
}
|
|
330
|
-
timers.push(window.setTimeout(() => {
|
|
331
|
-
setTraceOpen(false);
|
|
332
|
-
setReplyReady(true);
|
|
333
|
-
}, motionBeatSeconds(traceSteps.length * traceBeats.step + traceBeats.hold) * 1000));
|
|
334
|
-
return () => {
|
|
335
|
-
for (const timer of timers) window.clearTimeout(timer);
|
|
336
|
-
};
|
|
337
|
-
}, [reduce]);
|
|
338
|
-
|
|
339
|
-
const settleSeconds = readMotionDurationSeconds("fast");
|
|
340
|
-
const staggerSeconds = motionStaggerSeconds();
|
|
341
|
-
|
|
342
|
-
useEffect(() => {
|
|
343
|
-
if (!replyReady || streamDone) return;
|
|
344
|
-
const last = replyParts.length - 1;
|
|
345
|
-
const wait = (partDelay(last, reduce, settleSeconds, staggerSeconds) + settleSeconds) * 1000;
|
|
346
|
-
const timer = window.setTimeout(() => setStreamDone(true), wait);
|
|
347
|
-
return () => window.clearTimeout(timer);
|
|
348
|
-
}, [replyReady, reduce, streamDone, settleSeconds, staggerSeconds]);
|
|
349
|
-
|
|
350
|
-
useEffect(() => {
|
|
351
|
-
if (!actionsOpen) return;
|
|
352
|
-
const onPointerDown = (event: PointerEvent) => {
|
|
353
|
-
const target = event.target;
|
|
354
|
-
if (!(target instanceof Node)) return;
|
|
355
|
-
const block = (target as Element).closest?.("[data-reply-actions]");
|
|
356
|
-
if (block != null) return;
|
|
357
|
-
setActionsOpen(false);
|
|
358
|
-
};
|
|
359
|
-
window.addEventListener("pointerdown", onPointerDown);
|
|
360
|
-
return () => window.removeEventListener("pointerdown", onPointerDown);
|
|
361
|
-
}, [actionsOpen]);
|
|
362
|
-
|
|
363
|
-
useLayoutEffect(() => {
|
|
364
|
-
if (!replyReady) return;
|
|
365
|
-
onReplyGrown();
|
|
366
|
-
}, [replyReady, streamDone, onReplyGrown]);
|
|
367
|
-
|
|
368
|
-
function copyReply() {
|
|
369
|
-
if (typeof navigator === "undefined" || navigator.clipboard == null) return;
|
|
370
|
-
void navigator.clipboard.writeText(sampleReply).catch(() => undefined);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
return (
|
|
374
|
-
<>
|
|
375
|
-
<motion.p
|
|
376
|
-
className={userClasses}
|
|
377
|
-
initial={reduce || arrived ? false : { opacity: 0 }}
|
|
378
|
-
animate={{ opacity: 1 }}
|
|
379
|
-
transition={reduce ? { duration: 0 } : fast}
|
|
380
|
-
>
|
|
381
|
-
{prompt}
|
|
382
|
-
</motion.p>
|
|
383
|
-
{replyReady ? null : (
|
|
384
|
-
<div className={traceClasses} aria-busy="true">
|
|
385
|
-
<Button layout="row" width="hug" role="ghost" type="button" aria-expanded={traceOpen} onClick={() => setTraceOpen((current) => !current)}>
|
|
386
|
-
<Sparkle className={traceIconClasses} strokeWidth={2} aria-hidden />
|
|
387
|
-
<span className={thoughtLabelClasses}>{thoughtForLabel(thoughtSeconds)}</span>
|
|
388
|
-
<ChevronDown className={traceChevronClasses + (traceOpen ? " rotate-180" : "")} strokeWidth={2} aria-hidden />
|
|
389
|
-
</Button>
|
|
390
|
-
{traceOpen ? (
|
|
391
|
-
<div className={traceBodyClasses}>
|
|
392
|
-
{traceSteps.slice(0, revealed).map((step, index) => (
|
|
393
|
-
<motion.div
|
|
394
|
-
key={step}
|
|
395
|
-
className={traceLineClasses}
|
|
396
|
-
{...motionBlurReveal(reduce)}
|
|
397
|
-
transition={reduce ? { duration: 0 } : fast}
|
|
398
|
-
>
|
|
399
|
-
{index < resolved ? (
|
|
400
|
-
<Check className={traceIconClasses} strokeWidth={2} aria-hidden />
|
|
401
|
-
) : (
|
|
402
|
-
<LoaderCircle className={traceSpinClasses} strokeWidth={2} aria-hidden />
|
|
403
|
-
)}
|
|
404
|
-
{step}
|
|
405
|
-
</motion.div>
|
|
406
|
-
))}
|
|
407
|
-
</div>
|
|
408
|
-
) : null}
|
|
409
|
-
</div>
|
|
410
|
-
)}
|
|
411
|
-
{replyReady ? (
|
|
412
|
-
<>
|
|
413
|
-
<div
|
|
414
|
-
className={replyBlockClasses}
|
|
415
|
-
data-reply-actions=""
|
|
416
|
-
data-actions={actionsOpen ? "open" : undefined}
|
|
417
|
-
onClick={() => {
|
|
418
|
-
if (typeof window === "undefined") return;
|
|
419
|
-
if (!window.matchMedia("(hover: none)").matches) return;
|
|
420
|
-
setActionsOpen(true);
|
|
421
|
-
}}
|
|
422
|
-
>
|
|
423
|
-
<p className={replyClasses} aria-busy={streamDone ? undefined : true}>
|
|
424
|
-
{replyParts.map((part, index) => (
|
|
425
|
-
<ReplyWord
|
|
426
|
-
key={part.kind + "-" + index}
|
|
427
|
-
part={part}
|
|
428
|
-
index={index}
|
|
429
|
-
reduce={reduce}
|
|
430
|
-
delay={partDelay(index, reduce, settleSeconds, staggerSeconds)}
|
|
431
|
-
settle={fast}
|
|
432
|
-
onDone={index === replyParts.length - 1 ? () => setStreamDone(true) : undefined}
|
|
433
|
-
/>
|
|
434
|
-
))}
|
|
435
|
-
</p>
|
|
436
|
-
{streamDone ? (
|
|
437
|
-
<div className={actionsClasses}>
|
|
438
|
-
<IconButton aria-label="Copy reply" size="sm" icon={<Copy />} onClick={copyReply} />
|
|
439
|
-
<IconButton
|
|
440
|
-
aria-label="Mark this reply helpful"
|
|
441
|
-
size="sm"
|
|
442
|
-
icon={<ThumbsUp />}
|
|
443
|
-
aria-pressed={mark === "up"}
|
|
444
|
-
onClick={() => setMark((current) => (current === "up" ? null : "up"))}
|
|
445
|
-
/>
|
|
446
|
-
<IconButton
|
|
447
|
-
aria-label="Mark this reply not helpful"
|
|
448
|
-
size="sm"
|
|
449
|
-
icon={<ThumbsDown />}
|
|
450
|
-
aria-pressed={mark === "down"}
|
|
451
|
-
onClick={() => setMark((current) => (current === "down" ? null : "down"))}
|
|
452
|
-
/>
|
|
453
|
-
</div>
|
|
454
|
-
) : null}
|
|
455
|
-
</div>
|
|
456
|
-
{streamDone && latest ? (
|
|
457
|
-
<div className={followUpsClasses}>
|
|
458
|
-
{followUps.map((next) => (
|
|
459
|
-
<Button key={next} role="outline" emphasis="quiet" align="start" size="md" type="button" className={followUpClasses} onClick={() => onFollowUp(next)}>
|
|
460
|
-
{next}
|
|
461
|
-
</Button>
|
|
462
|
-
))}
|
|
463
|
-
</div>
|
|
464
|
-
) : null}
|
|
465
|
-
</>
|
|
466
|
-
) : null}
|
|
467
|
-
</>
|
|
468
|
-
);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function StartGate({
|
|
472
|
-
phase,
|
|
473
|
-
needs,
|
|
474
|
-
onNeedsChange,
|
|
475
|
-
budget,
|
|
476
|
-
onBudgetChange,
|
|
477
|
-
about,
|
|
478
|
-
onAboutChange,
|
|
479
|
-
onCancel,
|
|
480
|
-
onBack,
|
|
481
|
-
onNext,
|
|
482
|
-
onBooked,
|
|
483
|
-
onEmailed,
|
|
484
|
-
onDone,
|
|
485
|
-
}: {
|
|
486
|
-
phase: IntakePhase;
|
|
487
|
-
needs: readonly string[];
|
|
488
|
-
onNeedsChange: (values: string[]) => void;
|
|
489
|
-
budget: string | null;
|
|
490
|
-
onBudgetChange: (value: string) => void;
|
|
491
|
-
about: IntakeAboutValues;
|
|
492
|
-
onAboutChange: (values: IntakeAboutValues) => void;
|
|
493
|
-
onCancel: () => void;
|
|
494
|
-
onBack: () => void;
|
|
495
|
-
onNext: () => void;
|
|
496
|
-
onBooked: () => void;
|
|
497
|
-
onEmailed: () => void;
|
|
498
|
-
onDone: () => void;
|
|
499
|
-
}) {
|
|
500
|
-
const step: IntakeStep = phase.kind === "step" ? phase.step : 4;
|
|
501
|
-
const copy = startGateCopy[step];
|
|
502
|
-
const atStart = phase.kind !== "step" || phase.step <= 1;
|
|
503
|
-
const hideContinue = phase.kind === "done" || (phase.kind === "step" && phase.step === 4);
|
|
504
|
-
const hideFooter = phase.kind === "done";
|
|
505
|
-
const continueDisabled =
|
|
506
|
-
phase.kind !== "step" || !canContinueStart(phase.step, needs, budget, about);
|
|
507
|
-
const stepOneOpen = phase.kind === "step" && phase.step === 1;
|
|
508
|
-
|
|
509
|
-
function toggleNeed(value: string, checked: boolean) {
|
|
510
|
-
if (checked) {
|
|
511
|
-
onNeedsChange([...needs, value]);
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
onNeedsChange(needs.filter((item) => item !== value));
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
useKbdChoiceKeys({
|
|
518
|
-
enabled: stepOneOpen,
|
|
519
|
-
choices: Object.fromEntries(
|
|
520
|
-
startOptions.map((option) => [
|
|
521
|
-
String(option.number),
|
|
522
|
-
() => toggleNeed(option.value, !needs.includes(option.value)),
|
|
523
|
-
]),
|
|
524
|
-
),
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
return (
|
|
528
|
-
<Card shape="rounded" padding="none" variant="surface" aria-label={copy.title} className={startGateCardClasses}>
|
|
529
|
-
<Card.Header
|
|
530
|
-
start={
|
|
531
|
-
phase.kind === "done" ? (
|
|
532
|
-
<h2 className={cardTitleClasses}>Start a project</h2>
|
|
533
|
-
) : (
|
|
534
|
-
<>
|
|
535
|
-
<h2 className={cardTitleClasses}>{copy.title}</h2>
|
|
536
|
-
<p className={cardSubtitleClasses}>{copy.subtitle}</p>
|
|
537
|
-
</>
|
|
538
|
-
)
|
|
539
|
-
}
|
|
540
|
-
end={
|
|
541
|
-
<>
|
|
542
|
-
{phase.kind === "step" ? (
|
|
543
|
-
<>
|
|
544
|
-
<IconButton aria-label="Previous step" size="sm" icon={<ChevronLeft />} disabled={atStart} onClick={onBack} />
|
|
545
|
-
<span className={startGateStepClasses}>{step} of 4</span>
|
|
546
|
-
<IconButton aria-label="Next step" size="sm" icon={<ChevronRight />} disabled={hideContinue || continueDisabled} onClick={onNext} />
|
|
547
|
-
</>
|
|
548
|
-
) : null}
|
|
549
|
-
<IconButton aria-label="Close starter" size="sm" icon={<X />} onClick={onCancel} />
|
|
550
|
-
</>
|
|
551
|
-
}
|
|
552
|
-
/>
|
|
553
|
-
<Card.Body>
|
|
554
|
-
<div className={cardLayoutBodyOccupantWellClasses + " " + cardLayoutBodyOccupantInsetXClasses + " " + startGateOccupantClasses}>
|
|
555
|
-
{phase.kind === "done" ? <IntakeConfirmation variant={phase.variant} onDone={onDone} /> : null}
|
|
556
|
-
{phase.kind === "step" && phase.step === 1 ? (
|
|
557
|
-
<div className={startGateOptionsClasses} role="group" aria-label={copy.subtitle}>
|
|
558
|
-
{startOptions.map((option) => (
|
|
559
|
-
<div key={option.value} className={startGateOptionClasses}>
|
|
560
|
-
<Checkbox
|
|
561
|
-
className="min-w-0 flex-1"
|
|
562
|
-
size="md"
|
|
563
|
-
label={option.label}
|
|
564
|
-
description={option.description}
|
|
565
|
-
checked={needs.includes(option.value)}
|
|
566
|
-
onChange={(event) => toggleNeed(option.value, event.target.checked)}
|
|
567
|
-
/>
|
|
568
|
-
<Kbd className={startGateOptionNumberClasses} aria-label={"Press " + option.number}>
|
|
569
|
-
{option.number}
|
|
570
|
-
</Kbd>
|
|
571
|
-
</div>
|
|
572
|
-
))}
|
|
573
|
-
</div>
|
|
574
|
-
) : null}
|
|
575
|
-
{phase.kind === "step" && phase.step === 2 ? (
|
|
576
|
-
<PillGroup aria-label="Budget" value={budget} onValueChange={onBudgetChange}>
|
|
577
|
-
{startBudgets.map((option) => (
|
|
578
|
-
<PillGroup.Item key={option.value} value={option.value} emphasis={option.emphasis}>
|
|
579
|
-
{option.label}
|
|
580
|
-
</PillGroup.Item>
|
|
581
|
-
))}
|
|
582
|
-
</PillGroup>
|
|
583
|
-
) : null}
|
|
584
|
-
{phase.kind === "step" && phase.step === 3 ? (
|
|
585
|
-
<IntakeForm values={about} onChange={onAboutChange} />
|
|
586
|
-
) : null}
|
|
587
|
-
{phase.kind === "step" && phase.step === 4 ? (
|
|
588
|
-
<CalEmbed skip={false}>
|
|
589
|
-
<Button role="primary" type="button" onClick={onBooked}>Confirm this time</Button>
|
|
590
|
-
</CalEmbed>
|
|
591
|
-
) : null}
|
|
592
|
-
</div>
|
|
593
|
-
</Card.Body>
|
|
594
|
-
{hideFooter ? null : (
|
|
595
|
-
<Card.Footer>
|
|
596
|
-
{phase.kind === "step" && phase.step === 4 ? (
|
|
597
|
-
<CalEmbed.Skip onSkip={onEmailed} />
|
|
598
|
-
) : null}
|
|
599
|
-
<div className="ml-auto flex items-center gap-2">
|
|
600
|
-
<Button role="secondary" size="md" type="button" onClick={onCancel}>Cancel</Button>
|
|
601
|
-
{hideContinue ? null : (
|
|
602
|
-
<Button role="primary" size="md" type="button" disabled={continueDisabled} onClick={onNext}>Next</Button>
|
|
603
|
-
)}
|
|
604
|
-
</div>
|
|
605
|
-
</Card.Footer>
|
|
606
|
-
)}
|
|
607
|
-
</Card>
|
|
608
|
-
);
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
/**
|
|
612
|
-
* `initialPrompt` hands off from the marketing composer: read it from the URL (`/ask?q=…`)
|
|
613
|
-
* and the page opens on that first exchange instead of the landing headline.
|
|
614
|
-
*/
|
|
615
|
-
export function AskWhatMatters({ initialPrompt }: { initialPrompt?: string } = {}) {
|
|
616
|
-
const reduce = useReducedMotion() === true;
|
|
617
|
-
const travel = motionTransitionProp("medium");
|
|
618
|
-
const fieldRef = useRef<HTMLTextAreaElement>(null);
|
|
619
|
-
const headlineRef = useRef<HTMLHeadingElement>(null);
|
|
620
|
-
const stageRef = useRef<HTMLElement>(null);
|
|
621
|
-
const threadRef = useRef<HTMLDivElement>(null);
|
|
622
|
-
const composerRef = useRef<HTMLDivElement>(null);
|
|
623
|
-
const stickToEnd = useRef(true);
|
|
624
|
-
const [startGateOpen, setStartGateOpen] = useState(false);
|
|
625
|
-
const [startPhase, setStartPhase] = useState<IntakePhase>({ kind: "step", step: 1 });
|
|
626
|
-
const [startNeeds, setStartNeeds] = useState<string[]>([]);
|
|
627
|
-
const [startBudget, setStartBudget] = useState<string | null>(null);
|
|
628
|
-
const [startAbout, setStartAbout] = useState<IntakeAboutValues>(intakeAboutEmpty);
|
|
629
|
-
const suppressScroll = useRef(false);
|
|
630
|
-
const nextId = useRef(1);
|
|
631
|
-
const [draft, setDraft] = useState("");
|
|
632
|
-
const [turns, setTurns] = useState<Turn[]>(() => {
|
|
633
|
-
const first = initialPrompt?.trim();
|
|
634
|
-
return first ? [{ id: 0, kind: "prompt", prompt: first }] : [];
|
|
635
|
-
});
|
|
636
|
-
const [headlineExit, setHeadlineExit] = useState<Point | null>(null);
|
|
637
|
-
const [composerSpace, setComposerSpace] = useState(0);
|
|
638
|
-
const chatting = turns.length > 0;
|
|
639
|
-
|
|
640
|
-
const scrollStageToEnd = useCallback(() => {
|
|
641
|
-
const stage = stageRef.current;
|
|
642
|
-
if (stage == null || !stickToEnd.current) return;
|
|
643
|
-
const before = stage.scrollTop;
|
|
644
|
-
suppressScroll.current = true;
|
|
645
|
-
stage.scrollTop = stage.scrollHeight;
|
|
646
|
-
if (stage.scrollTop === before) suppressScroll.current = false;
|
|
647
|
-
}, []);
|
|
648
|
-
|
|
649
|
-
useLayoutEffect(() => {
|
|
650
|
-
const composer = composerRef.current;
|
|
651
|
-
if (composer == null) return;
|
|
652
|
-
const apply = () => {
|
|
653
|
-
const stage = stageRef.current;
|
|
654
|
-
const height = composer.offsetHeight;
|
|
655
|
-
const composerBottom = composer.getBoundingClientRect().bottom;
|
|
656
|
-
const bottoms = [stage, stage?.parentElement]
|
|
657
|
-
.filter((node) => node instanceof HTMLElement)
|
|
658
|
-
.map((node) => node.getBoundingClientRect().bottom);
|
|
659
|
-
if (typeof window !== "undefined") bottoms.push(window.innerHeight);
|
|
660
|
-
const tail = bottoms.reduce((extra, bottom) => Math.max(extra, bottom - composerBottom), 0);
|
|
661
|
-
const next = Math.ceil(height + Math.max(0, tail));
|
|
662
|
-
setComposerSpace((current) => (current === next ? current : next));
|
|
663
|
-
};
|
|
664
|
-
apply();
|
|
665
|
-
if (typeof ResizeObserver === "undefined") return;
|
|
666
|
-
const observer = new ResizeObserver(apply);
|
|
667
|
-
observer.observe(composer);
|
|
668
|
-
const stage = stageRef.current;
|
|
669
|
-
if (stage != null) observer.observe(stage);
|
|
670
|
-
if (stage?.parentElement != null) observer.observe(stage.parentElement);
|
|
671
|
-
return () => observer.disconnect();
|
|
672
|
-
}, []);
|
|
673
|
-
|
|
674
|
-
useEffect(() => {
|
|
675
|
-
const stage = stageRef.current;
|
|
676
|
-
if (stage == null) return;
|
|
677
|
-
const onScroll = () => {
|
|
678
|
-
if (suppressScroll.current) {
|
|
679
|
-
suppressScroll.current = false;
|
|
680
|
-
return;
|
|
681
|
-
}
|
|
682
|
-
const distance = stage.scrollHeight - stage.clientHeight - stage.scrollTop;
|
|
683
|
-
stickToEnd.current = distance <= 4;
|
|
684
|
-
};
|
|
685
|
-
stage.addEventListener("scroll", onScroll, { passive: true });
|
|
686
|
-
return () => stage.removeEventListener("scroll", onScroll);
|
|
687
|
-
}, []);
|
|
688
|
-
|
|
689
|
-
useEffect(() => {
|
|
690
|
-
const thread = threadRef.current;
|
|
691
|
-
if (thread == null || typeof ResizeObserver === "undefined") return;
|
|
692
|
-
const observer = new ResizeObserver(() => {
|
|
693
|
-
scrollStageToEnd();
|
|
694
|
-
});
|
|
695
|
-
observer.observe(thread);
|
|
696
|
-
return () => observer.disconnect();
|
|
697
|
-
}, [chatting, scrollStageToEnd]);
|
|
698
|
-
|
|
699
|
-
useLayoutEffect(() => {
|
|
700
|
-
scrollStageToEnd();
|
|
701
|
-
}, [turns.length, composerSpace, scrollStageToEnd]);
|
|
702
|
-
|
|
703
|
-
function closeStartGate() {
|
|
704
|
-
setStartGateOpen(false);
|
|
705
|
-
setStartPhase({ kind: "step", step: 1 });
|
|
706
|
-
setStartNeeds([]);
|
|
707
|
-
setStartBudget(null);
|
|
708
|
-
setStartAbout(intakeAboutEmpty);
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
function openStartGate() {
|
|
712
|
-
setStartPhase({ kind: "step", step: 1 });
|
|
713
|
-
setStartNeeds([]);
|
|
714
|
-
setStartBudget(null);
|
|
715
|
-
setStartAbout(intakeAboutEmpty);
|
|
716
|
-
setStartGateOpen(true);
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
function goHome() {
|
|
720
|
-
setTurns([]);
|
|
721
|
-
setDraft("");
|
|
722
|
-
setHeadlineExit(null);
|
|
723
|
-
closeStartGate();
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
function onBrandClick(event: MouseEvent<HTMLDivElement>) {
|
|
727
|
-
const target = event.target;
|
|
728
|
-
if (!(target instanceof Element)) return;
|
|
729
|
-
const home = target.closest("a[aria-label='WhatMatters']");
|
|
730
|
-
if (home == null) return;
|
|
731
|
-
event.preventDefault();
|
|
732
|
-
goHome();
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
function send(value: string) {
|
|
736
|
-
const text = value.trim();
|
|
737
|
-
if (text.length === 0) return;
|
|
738
|
-
stickToEnd.current = true;
|
|
739
|
-
const fromLanding = turns.length === 0;
|
|
740
|
-
const title = headlineRef.current?.getBoundingClientRect();
|
|
741
|
-
const id = nextId.current;
|
|
742
|
-
nextId.current += 1;
|
|
743
|
-
setDraft("");
|
|
744
|
-
setTurns((current) => [...current, { id, kind: "prompt", prompt: text }]);
|
|
745
|
-
if (reduce || !fromLanding || title == null) {
|
|
746
|
-
setHeadlineExit(null);
|
|
747
|
-
return;
|
|
748
|
-
}
|
|
749
|
-
setHeadlineExit({ top: title.top, left: title.left });
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function finishStartGate() {
|
|
753
|
-
const outcome = startPhase.kind === "done" ? startPhase.variant : "emailed";
|
|
754
|
-
const pairs = intakeQaPairs(startNeeds, startBudget, startAbout, outcome);
|
|
755
|
-
stickToEnd.current = true;
|
|
756
|
-
const id = nextId.current;
|
|
757
|
-
nextId.current += 1;
|
|
758
|
-
setTurns((current) => [...current, { id, kind: "intake", pairs }]);
|
|
759
|
-
closeStartGate();
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
return (
|
|
763
|
-
<ConfettiProvider>
|
|
764
|
-
<div className={pageClasses}>
|
|
765
|
-
{chatting ? (
|
|
766
|
-
<div onClick={onBrandClick}>
|
|
767
|
-
<SiteNav
|
|
768
|
-
start={<SiteNav.Brand href="/" aria-label="WhatMatters" icon={<Sparkles />} />}
|
|
769
|
-
middle={
|
|
770
|
-
<SiteNav.Links>
|
|
771
|
-
<SiteNav.Link href="/product" current>Product</SiteNav.Link>
|
|
772
|
-
<SiteNav.Link href="/pricing">Pricing</SiteNav.Link>
|
|
773
|
-
<SiteNav.Link href="/customers">Customers</SiteNav.Link>
|
|
774
|
-
</SiteNav.Links>
|
|
775
|
-
}
|
|
776
|
-
end={
|
|
777
|
-
<>
|
|
778
|
-
<Button role="ghost" size="sm" render={<a href="/signin" />} className="whitespace-nowrap">Sign in</Button>
|
|
779
|
-
<Button role="primary" size="sm" type="button" className="whitespace-nowrap" onClick={openStartGate}>Start Project</Button>
|
|
780
|
-
</>
|
|
781
|
-
}
|
|
782
|
-
mobile={
|
|
783
|
-
<>
|
|
784
|
-
<SiteNav.MobileLink href="/product" current>Product</SiteNav.MobileLink>
|
|
785
|
-
<SiteNav.MobileLink href="/pricing">Pricing</SiteNav.MobileLink>
|
|
786
|
-
<SiteNav.MobileLink href="/customers">Customers</SiteNav.MobileLink>
|
|
787
|
-
</>
|
|
788
|
-
}
|
|
789
|
-
/>
|
|
790
|
-
</div>
|
|
791
|
-
) : null}
|
|
792
|
-
<div className={columnClasses}>
|
|
793
|
-
<main ref={stageRef} className={chatting ? stageClasses : stageClasses + " " + landingClasses}>
|
|
794
|
-
{chatting ? (
|
|
795
|
-
<div ref={threadRef} className={threadClasses} style={{ paddingBottom: composerSpace }}>
|
|
796
|
-
{turns.map((turn, index) =>
|
|
797
|
-
turn.kind === "intake" ? (
|
|
798
|
-
<IntakeExchange
|
|
799
|
-
key={turn.id}
|
|
800
|
-
pairs={turn.pairs}
|
|
801
|
-
latest={index === turns.length - 1}
|
|
802
|
-
reduce={reduce}
|
|
803
|
-
onFollowUp={send}
|
|
804
|
-
onReplyGrown={scrollStageToEnd}
|
|
805
|
-
/>
|
|
806
|
-
) : (
|
|
807
|
-
<Exchange
|
|
808
|
-
key={turn.id}
|
|
809
|
-
prompt={turn.prompt}
|
|
810
|
-
arrived={turn.id === 0}
|
|
811
|
-
latest={index === turns.length - 1}
|
|
812
|
-
reduce={reduce}
|
|
813
|
-
onFollowUp={send}
|
|
814
|
-
onReplyGrown={scrollStageToEnd}
|
|
815
|
-
/>
|
|
816
|
-
),
|
|
817
|
-
)}
|
|
818
|
-
</div>
|
|
819
|
-
) : (
|
|
820
|
-
<h1 ref={headlineRef} className={headlineClasses}>{headline}</h1>
|
|
821
|
-
)}
|
|
822
|
-
</main>
|
|
823
|
-
<div className={barClasses}>
|
|
824
|
-
<div ref={composerRef} className={composerClasses}>
|
|
825
|
-
{startGateOpen ? (
|
|
826
|
-
<StartGate
|
|
827
|
-
phase={startPhase}
|
|
828
|
-
needs={startNeeds}
|
|
829
|
-
onNeedsChange={setStartNeeds}
|
|
830
|
-
budget={startBudget}
|
|
831
|
-
onBudgetChange={setStartBudget}
|
|
832
|
-
about={startAbout}
|
|
833
|
-
onAboutChange={setStartAbout}
|
|
834
|
-
onCancel={closeStartGate}
|
|
835
|
-
onBack={() => {
|
|
836
|
-
if (startPhase.kind !== "step" || startPhase.step <= 1) return;
|
|
837
|
-
setStartPhase({ kind: "step", step: (startPhase.step - 1) as IntakeStep });
|
|
838
|
-
}}
|
|
839
|
-
onNext={() => {
|
|
840
|
-
if (startPhase.kind !== "step") return;
|
|
841
|
-
if (!canContinueStart(startPhase.step, startNeeds, startBudget, startAbout)) return;
|
|
842
|
-
if (startPhase.step >= 4) return;
|
|
843
|
-
setStartPhase({ kind: "step", step: (startPhase.step + 1) as IntakeStep });
|
|
844
|
-
}}
|
|
845
|
-
onBooked={() => setStartPhase({ kind: "done", variant: "booked" })}
|
|
846
|
-
onEmailed={() => setStartPhase({ kind: "done", variant: "emailed" })}
|
|
847
|
-
onDone={finishStartGate}
|
|
848
|
-
/>
|
|
849
|
-
) : (
|
|
850
|
-
<PromptBar ref={fieldRef} value={draft} onValueChange={setDraft} onSend={send} />
|
|
851
|
-
)}
|
|
852
|
-
</div>
|
|
853
|
-
</div>
|
|
854
|
-
</div>
|
|
855
|
-
{headlineExit != null ? (
|
|
856
|
-
<motion.p
|
|
857
|
-
aria-hidden
|
|
858
|
-
className={headlineClasses + " pointer-events-none fixed z-10 m-0"}
|
|
859
|
-
style={{ top: headlineExit.top, left: headlineExit.left }}
|
|
860
|
-
initial={{ opacity: 1, y: 0 }}
|
|
861
|
-
animate={{ opacity: 0, y: -12 }}
|
|
862
|
-
transition={travel}
|
|
863
|
-
onAnimationComplete={() => setHeadlineExit(null)}
|
|
864
|
-
>
|
|
865
|
-
{headline}
|
|
866
|
-
</motion.p>
|
|
867
|
-
) : null}
|
|
868
|
-
</div>
|
|
869
|
-
</ConfettiProvider>
|
|
870
|
-
);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
function ReplyWord({
|
|
874
|
-
part,
|
|
875
|
-
index,
|
|
876
|
-
reduce,
|
|
877
|
-
delay,
|
|
878
|
-
settle,
|
|
879
|
-
onDone,
|
|
880
|
-
}: {
|
|
881
|
-
part: ReplyPart;
|
|
882
|
-
index: number;
|
|
883
|
-
reduce: boolean;
|
|
884
|
-
delay: number;
|
|
885
|
-
settle: Transition;
|
|
886
|
-
onDone?: () => void;
|
|
887
|
-
}) {
|
|
888
|
-
return (
|
|
889
|
-
<motion.span
|
|
890
|
-
className="inline"
|
|
891
|
-
{...motionBlurReveal(reduce)}
|
|
892
|
-
transition={reduce ? { duration: 0 } : { ...settle, delay }}
|
|
893
|
-
onAnimationComplete={onDone}
|
|
894
|
-
>
|
|
895
|
-
{index > 0 ? " " : null}
|
|
896
|
-
{part.kind === "source" ? <TextLink href={part.href}>{part.text}</TextLink> : part.text}
|
|
897
|
-
</motion.span>
|
|
898
|
-
);
|
|
899
|
-
}
|