Skip to content

Commit d12654a

Browse files
authored
"Was this page helpful?" (#1015)
1 parent b3448a9 commit d12654a

File tree

11 files changed

+308
-5
lines changed

11 files changed

+308
-5
lines changed

.gitignore

+1-1
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ web_modules/
122122
.yarn-integrity
123123

124124
# dotenv environment variable files
125-
.env
125+
client/sandbox/**/.env
126126
.env.development.local
127127
.env.test.local
128128
.env.production.local

client/Makefile

+14
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,17 @@ text-email:
3737
html-email:
3838
echo "Generating HTML version email..."
3939
pandoc -f markdown -t html www/_emails/markdown/$(slug).md -o www/_emails/html/$(slug).html
40+
41+
feedback-app-push:
42+
cd www && \
43+
FEEDBACK_APP_ID=$$(grep NEXT_PUBLIC_FEEDBACK_APP_ID .env | cut -d '=' -f2) \
44+
INSTANT_SCHEMA_FILE_PATH=./lib/feedback/instant.schema.ts \
45+
INSTANT_PERMS_FILE_PATH=./lib/feedback/instant.perms.ts \
46+
pnpm exec instant-cli push --app=$$FEEDBACK_APP_ID
47+
48+
feedback-app-pull:
49+
cd www && \
50+
FEEDBACK_APP_ID=$$(grep NEXT_PUBLIC_FEEDBACK_APP_ID .env | cut -d '=' -f2) \
51+
INSTANT_SCHEMA_FILE_PATH=./lib/feedback/instant.schema.ts \
52+
INSTANT_PERMS_FILE_PATH=./lib/feedback/instant.perms.ts \
53+
pnpm exec instant-cli pull --app=$$FEEDBACK_APP_ID

client/packages/react/src/InstantReactAbstractDatabase.ts

+38-1
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,47 @@ export default abstract class InstantReactAbstractDatabase<
7171
this.storage = this._core.storage;
7272
}
7373

74-
getLocalId = (name: string) => {
74+
/**
75+
* Returns a unique ID for a given `name`. It's stored in local storage,
76+
* so you will get the same ID across sessions.
77+
*
78+
* This is useful for generating IDs that could identify a local device or user.
79+
*
80+
* @example
81+
* const deviceId = await db.getLocalId('device');
82+
*/
83+
getLocalId = (name: string): Promise<string> => {
7584
return this._core.getLocalId(name);
7685
};
7786

87+
/**
88+
* A hook that returns a unique ID for a given `name`. localIds are
89+
* stored in local storage, so you will get the same ID across sessions.
90+
*
91+
* Initially returns `null`, and then loads the localId.
92+
*
93+
* @example
94+
* const deviceId = db.useLocalId('device');
95+
* if (!deviceId) return null; // loading
96+
* console.log('Device ID:', deviceId)
97+
*/
98+
useLocalId = (name: string): string | null => {
99+
const [localId, setLocalId] = useState<string | null>(null);
100+
101+
useEffect(() => {
102+
let mounted = true;
103+
const f = async () => {
104+
const id = await this.getLocalId(name);
105+
if (!mounted) return;
106+
setLocalId(id);
107+
};
108+
f();
109+
return;
110+
}, [name]);
111+
112+
return localId;
113+
};
114+
78115
/**
79116
* Obtain a handle to a room, which allows you to listen to topics and presence data
80117
*

client/pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/www/.env

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
NEXT_PUBLIC_FEEDBACK_APP_ID=5d9c6277-e6ac-42d6-8e51-2354b4870c05

client/www/components/docs/Layout.jsx

+5-2
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import { SelectedAppContext } from '@/lib/SelectedAppContext';
1010
import { useAuthToken, useTokenFetch } from '@/lib/auth';
1111
import config, { getLocal, setLocal } from '@/lib/config';
1212
import { Select } from '@/components/ui';
13-
import { MainNav, BareNav } from '@/components/marketingUi';
13+
import { BareNav } from '@/components/marketingUi';
1414
import navigation from '@/data/docsNavigation';
1515
import { createdAtComparator } from '@/lib/app';
16+
import RatingBox from '@/lib/feedback/RatingBox';
1617

1718
function useSelectedApp(apps = []) {
1819
const cacheKey = 'docs-appId';
@@ -346,7 +347,9 @@ export function Layout({ children, title, tableOfContents }) {
346347
>
347348
{children}
348349
</PageContent>
349-
350+
<div className="mt-4">
351+
<RatingBox pageId={router.pathname} />
352+
</div>
350353
<PageNav previousPage={previousPage} nextPage={nextPage} />
351354
</main>
352355

client/www/lib/feedback/RatingBox.tsx

+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { id } from '@instantdb/react';
3+
import clientDB from '@/lib/feedback/clientDB';
4+
import { BlockHeading, Button, ToggleGroup } from '@/components/ui';
5+
import { Rating } from '@/lib/feedback/instant.schema';
6+
7+
/**
8+
* A handy component to collect feedback for a particular page.
9+
*
10+
* Asks "Was this page helpful?", and lets the user
11+
* provide more details if they want too.
12+
*
13+
* No login required!
14+
*/
15+
export default function RatingBoxContainer({ pageId }: { pageId: string }) {
16+
const localId = clientDB.useLocalId('feedback');
17+
18+
const { isLoading, error, data } = clientDB.useQuery(
19+
localId
20+
? {
21+
ratings: {
22+
$: {
23+
where: { pageId, localId },
24+
},
25+
},
26+
}
27+
: null,
28+
{ ruleParams: { localId } },
29+
);
30+
31+
if (!localId || isLoading || error) return null;
32+
33+
return (
34+
<RatingBox
35+
localId={localId}
36+
pageId={pageId}
37+
previousRating={data.ratings[0]}
38+
/>
39+
);
40+
}
41+
42+
function RatingBox({
43+
pageId,
44+
localId,
45+
previousRating,
46+
}: {
47+
pageId: string;
48+
localId: string;
49+
previousRating?: Rating;
50+
}) {
51+
// The first time you rate, let's auto-focus the 'details' textarea
52+
// so you can quickly add more details if you want to.
53+
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
54+
55+
const selectedId = previousRating
56+
? previousRating.wasHelpful
57+
? 'yes'
58+
: 'no'
59+
: undefined;
60+
return (
61+
<div className="space-y-2">
62+
<div className="inline-flex space-x-4 items-center">
63+
<BlockHeading>Was this page helpful?</BlockHeading>
64+
<div className="w-20">
65+
<ToggleGroup
66+
selectedId={selectedId}
67+
onChange={(item) => {
68+
if (!previousRating) {
69+
setShouldAutoFocus(true);
70+
}
71+
clientDB.transact(
72+
clientDB.tx.ratings[previousRating?.id ?? id()]
73+
.ruleParams({ localId })
74+
.update({
75+
pageId,
76+
localId,
77+
key: `${localId}_${pageId}`,
78+
wasHelpful: 'yes' === item.id,
79+
}),
80+
);
81+
}}
82+
items={[
83+
{ id: 'yes', label: 'Yes' },
84+
{ id: 'no', label: 'No' },
85+
]}
86+
/>
87+
</div>
88+
</div>
89+
{previousRating && selectedId && (
90+
<div className="space-y-2">
91+
<p>Thank you for your feedback! More details to share?</p>
92+
<SavingTextArea
93+
savedValue={previousRating.extraComment || ''}
94+
onSave={(extraComment) => {
95+
clientDB.transact(
96+
clientDB.tx.ratings[previousRating.id]
97+
.ruleParams({ localId })
98+
.update({ extraComment }),
99+
);
100+
}}
101+
placeholder="Tell us more about your experience..."
102+
autoFocus={shouldAutoFocus}
103+
/>
104+
</div>
105+
)}
106+
</div>
107+
);
108+
}
109+
110+
// ----------
111+
// Components
112+
113+
type SavingTextAreaProps = {
114+
savedValue: string;
115+
onSave: (value: string) => void;
116+
} & Omit<
117+
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
118+
'value' | 'onChange' | 'onKeyDown'
119+
>;
120+
121+
/**
122+
* A handy textarea that lets you save a value.
123+
* If the incoming `savedValue` changes, we'll update the textarea.
124+
* We'll _skip_ the update if you are focused on the input
125+
* and in the middle of making a change though! :)
126+
*/
127+
function SavingTextArea({ savedValue, onSave, ...props }: SavingTextAreaProps) {
128+
const textAreaRef = useRef<HTMLTextAreaElement>(null);
129+
const [value, setValue] = useState(savedValue);
130+
useEffect(() => {
131+
const ref = textAreaRef.current!;
132+
const isEditing = ref === document.activeElement;
133+
if (isEditing) return;
134+
setValue(savedValue);
135+
}, [savedValue]);
136+
return (
137+
<div className="space-y-1">
138+
<textarea
139+
{...props}
140+
value={value}
141+
className="flex w-full flex-1 rounded-sm border-gray-200 bg-white px-3 py-1 placeholder:text-gray-400 disabled:text-gray-400"
142+
onChange={(e) => {
143+
setValue(e.target.value);
144+
}}
145+
onKeyDown={(e) => {
146+
if (e.key === 'Enter' && e.metaKey) {
147+
onSave(value);
148+
}
149+
}}
150+
/>
151+
<div className="text-right">
152+
<Button
153+
disabled={value === savedValue}
154+
onClick={() => {
155+
onSave(value);
156+
}}
157+
size="mini"
158+
type="submit"
159+
>
160+
{value && value === savedValue ? 'Saved!' : 'Save'}
161+
</Button>
162+
</div>
163+
</div>
164+
);
165+
}

client/www/lib/feedback/clientDB.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { init } from '@instantdb/react';
2+
import schema from './instant.schema';
3+
import config from '@/lib/config';
4+
5+
const clientDB = init({
6+
appId: process.env.NEXT_PUBLIC_FEEDBACK_APP_ID!,
7+
schema,
8+
...config,
9+
});
10+
11+
export default clientDB;
+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Docs: https://www.instantdb.com/docs/permissions
2+
3+
import type { InstantRules } from '@instantdb/react';
4+
5+
const rules = {
6+
// We are in production. By default, let's disallow everything.
7+
$default: {
8+
allow: {
9+
$default: 'false',
10+
},
11+
},
12+
13+
ratings: {
14+
bind: [
15+
'isCreator',
16+
'ruleParams.localId == data.localId',
17+
18+
'hasValidKey',
19+
'(data.localId + "_" + data.pageId) == data.key',
20+
21+
'updatesAreValid',
22+
'newData.localId == data.localId && newData.pageId == data.pageId && newData.key == data.key',
23+
],
24+
allow: {
25+
// You can only see ratings you've creating
26+
view: 'isCreator',
27+
// You can only create ratings for yourself
28+
create: 'isCreator && hasValidKey',
29+
// You can update your rating, but can't change ownership
30+
update: 'isCreator && updatesAreValid',
31+
// You can delete your own ratings
32+
delete: 'isCreator',
33+
},
34+
},
35+
} satisfies InstantRules;
36+
37+
export default rules;
+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// Docs: https://www.instantdb.com/docs/modeling-data
2+
3+
import { i, InstaQLEntity } from '@instantdb/react';
4+
5+
const _schema = i.schema({
6+
entities: {
7+
ratings: i.entity({
8+
// Indexing so we can easily find all ratings for a page
9+
pageId: i.string().indexed(),
10+
localId: i.string(),
11+
// We'll use a unique key to make sure that a user
12+
// can only rate a particular page once.
13+
key: i.string().unique(),
14+
wasHelpful: i.boolean(),
15+
extraComment: i.string(),
16+
}),
17+
},
18+
links: {},
19+
rooms: {},
20+
});
21+
22+
// This helps Typescript display nicer intellisense
23+
type _AppSchema = typeof _schema;
24+
interface AppSchema extends _AppSchema {}
25+
const schema: AppSchema = _schema;
26+
27+
type Rating = InstaQLEntity<AppSchema, 'ratings'>;
28+
29+
export type { AppSchema, Rating };
30+
31+
export default schema;

client/www/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"postcss": "^8.4.5",
7676
"prettier-plugin-tailwindcss": "^0.1.1",
7777
"tailwindcss": "^3.0.7",
78-
"typescript": "5.5.4"
78+
"typescript": "5.5.4",
79+
"instant-cli": "workspace:*"
7980
}
8081
}

0 commit comments

Comments
 (0)