add feedback form

This commit is contained in:
Paul Miller
2023-06-19 14:31:53 -05:00
parent f5b5fd8307
commit edbdfcedc2
12 changed files with 395 additions and 32 deletions

View File

@@ -15,3 +15,4 @@ VITE_SELFHOSTED="true"
VITE_AUTH="https://auth-staging.mutinywallet.com" VITE_AUTH="https://auth-staging.mutinywallet.com"
VITE_SUBSCRIPTIONS="https://subscriptions-staging.mutinywallet.com" VITE_SUBSCRIPTIONS="https://subscriptions-staging.mutinywallet.com"
VITE_STORAGE="https://storage-staging.mutinywallet.com" VITE_STORAGE="https://storage-staging.mutinywallet.com"
VITE_FEEDBACK="https://feedback-staging.mutinywallet.com"

View File

@@ -43,8 +43,8 @@
"@capacitor/core": "^5.2.1", "@capacitor/core": "^5.2.1",
"@kobalte/core": "^0.9.8", "@kobalte/core": "^0.9.8",
"@kobalte/tailwindcss": "^0.5.0", "@kobalte/tailwindcss": "^0.5.0",
"@modular-forms/solid": "^0.13.2",
"@mutinywallet/mutiny-wasm": "0.4.4", "@mutinywallet/mutiny-wasm": "0.4.4",
"@modular-forms/solid": "^0.18.0",
"@mutinywallet/waila-wasm": "^0.2.1", "@mutinywallet/waila-wasm": "^0.2.1",
"@solid-primitives/upload": "^0.0.111", "@solid-primitives/upload": "^0.0.111",
"@solidjs/meta": "^0.28.5", "@solidjs/meta": "^0.28.5",

16
pnpm-lock.yaml generated
View File

@@ -1,5 +1,9 @@
lockfileVersion: '6.0' lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies: dependencies:
'@capacitor/android': '@capacitor/android':
specifier: ^5.2.1 specifier: ^5.2.1
@@ -17,8 +21,8 @@ dependencies:
specifier: ^0.5.0 specifier: ^0.5.0
version: 0.5.0(tailwindcss@3.3.2) version: 0.5.0(tailwindcss@3.3.2)
'@modular-forms/solid': '@modular-forms/solid':
specifier: ^0.13.2 specifier: ^0.18.0
version: 0.13.2(solid-js@1.7.7) version: 0.18.0(solid-js@1.7.7)
'@mutinywallet/barcode-scanner': '@mutinywallet/barcode-scanner':
specifier: 5.0.0-beta.3 specifier: 5.0.0-beta.3
version: 5.0.0-beta.3(@capacitor/core@5.2.1) version: 5.0.0-beta.3(@capacitor/core@5.2.1)
@@ -2093,8 +2097,8 @@ packages:
solid-js: 1.7.7 solid-js: 1.7.7
dev: false dev: false
/@modular-forms/solid@0.13.2(solid-js@1.7.7): /@modular-forms/solid@0.18.0(solid-js@1.7.7):
resolution: {integrity: sha512-wiwJUxbwDp8F2FI0hTYoydNumVRZruIiyjV+w91++zA0QofASuMWe3fs4lETHpaiQK/jJsGXhys7BFqXUf9SDg==} resolution: {integrity: sha512-0+MuHb8wIE5GPazZZqqaDop85w8w/DUBOxreYFx4o2/4jzVTlV1z/913EqPssxMYKJCG0rQSEVERKmJX3af3zw==}
peerDependencies: peerDependencies:
solid-js: ^1.3.1 solid-js: ^1.3.1
dependencies: dependencies:
@@ -8023,7 +8027,3 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
dev: true dev: true
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.667 1.66406H3.33366C2.41699 1.66406 1.67533 2.41406 1.67533 3.33073L1.66699 18.3307L5.00033 14.9974H16.667C17.5837 14.9974 18.3337 14.2474 18.3337 13.3307V3.33073C18.3337 2.41406 17.5837 1.66406 16.667 1.66406ZM5.83366 7.4974H14.167C14.6253 7.4974 15.0003 7.8724 15.0003 8.33073C15.0003 8.78906 14.6253 9.16406 14.167 9.16406H5.83366C5.37533 9.16406 5.00033 8.78906 5.00033 8.33073C5.00033 7.8724 5.37533 7.4974 5.83366 7.4974ZM10.8337 11.6641H5.83366C5.37533 11.6641 5.00033 11.2891 5.00033 10.8307C5.00033 10.3724 5.37533 9.9974 5.83366 9.9974H10.8337C11.292 9.9974 11.667 10.3724 11.667 10.8307C11.667 11.2891 11.292 11.6641 10.8337 11.6641ZM14.167 6.66406H5.83366C5.37533 6.66406 5.00033 6.28906 5.00033 5.83073C5.00033 5.3724 5.37533 4.9974 5.83366 4.9974H14.167C14.6253 4.9974 15.0003 5.3724 15.0003 5.83073C15.0003 6.28906 14.6253 6.66406 14.167 6.66406Z" fill="#B9B9B9"/>
</svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@@ -7,19 +7,17 @@ import { OnboardWarning } from "~/components/OnboardWarning";
import { CombinedActivity } from "./Activity"; import { CombinedActivity } from "./Activity";
import { useMegaStore } from "~/state/megaStore"; import { useMegaStore } from "~/state/megaStore";
import { Match, Show, Suspense, Switch } from "solid-js"; import { Match, Show, Suspense, Switch } from "solid-js";
import { ExternalLink } from "./layout/ExternalLink";
import { BetaWarningModal } from "~/components/BetaWarningModal"; import { BetaWarningModal } from "~/components/BetaWarningModal";
import settings from "~/assets/icons/settings.svg"; import settings from "~/assets/icons/settings.svg";
import pixelLogo from "~/assets/mutiny-pixel-logo.png"; import pixelLogo from "~/assets/mutiny-pixel-logo.png";
import plusLogo from "~/assets/mutiny-plus-logo.png"; import plusLogo from "~/assets/mutiny-plus-logo.png";
import { PendingNwc } from "./PendingNwc"; import { PendingNwc } from "./PendingNwc";
import { useI18n } from "~/i18n/context";
import { DecryptDialog } from "./DecryptDialog"; import { DecryptDialog } from "./DecryptDialog";
import { LoadingIndicator } from "./LoadingIndicator"; import { LoadingIndicator } from "./LoadingIndicator";
import { FeedbackLink } from "~/routes/Feedback";
export default function App() { export default function App() {
const [state, _actions] = useMegaStore(); const [state, _actions] = useMegaStore();
const i18n = useI18n();
return ( return (
<SafeArea> <SafeArea>
@@ -86,14 +84,9 @@ export default function App() {
</Suspense> </Suspense>
</VStack> </VStack>
</Card> </Card>
<p class="self-center text-neutral-500 mt-4 font-normal"> <div class="self-center mt-4">
Bugs? Feedback?{" "} <FeedbackLink />
<span class="text-neutral-400"> </div>
<ExternalLink href="https://github.com/MutinyWallet/mutiny-web/issues">
{i18n.t("create_an_issue")}
</ExternalLink>
</span>
</p>
</DefaultMain> </DefaultMain>
<DecryptDialog /> <DecryptDialog />
<BetaWarningModal /> <BetaWarningModal />

View File

@@ -11,19 +11,15 @@ import { Match, Switch } from "solid-js";
import { ImportExport } from "./ImportExport"; import { ImportExport } from "./ImportExport";
import { Logs } from "./Logs"; import { Logs } from "./Logs";
import { DeleteEverything } from "./DeleteEverything"; import { DeleteEverything } from "./DeleteEverything";
import { FeedbackLink } from "~/routes/Feedback";
function ErrorFooter() { function ErrorFooter() {
return ( return (
<> <>
<div class="h-full" /> <div class="h-full" />
<p class="self-center text-neutral-500 mt-4"> <div class="self-center mt-4">
Bugs? Feedback?{" "} <FeedbackLink setupError={true} />
<span class="text-neutral-400"> </div>
<ExternalLink href="https://github.com/MutinyWallet/mutiny-web/issues">
Create an issue
</ExternalLink>
</span>
</p>
</> </>
); );
} }

View File

@@ -0,0 +1,23 @@
import { useLocation, useNavigate } from "solid-start";
import { BackButton } from "./BackButton";
type StateWithPrevious = {
previous?: string;
};
export function BackPop() {
const navigate = useNavigate();
const location = useLocation();
const state = location.state as StateWithPrevious;
const backPath = () => (state?.previous ? state?.previous : "/");
return (
<BackButton
title="Back"
onClick={() => navigate(backPath())}
showOnDesktop
/>
);
}

View File

@@ -16,13 +16,15 @@ const button = cva(
glowy: "bg-black/10 shadow-xl text-white border border-m-blue hover:m-blue-dark hover:text-m-blue", glowy: "bg-black/10 shadow-xl text-white border border-m-blue hover:m-blue-dark hover:text-m-blue",
blue: "bg-m-blue text-white shadow-inner-button hover:bg-m-blue-dark text-shadow-button", blue: "bg-m-blue text-white shadow-inner-button hover:bg-m-blue-dark text-shadow-button",
red: "bg-m-red text-white shadow-inner-button hover:bg-m-red-dark text-shadow-button", red: "bg-m-red text-white shadow-inner-button hover:bg-m-red-dark text-shadow-button",
green: "bg-m-green text-white shadow-inner-button hover:bg-m-green-dark text-shadow-button" green: "bg-m-green text-white shadow-inner-button hover:bg-m-green-dark text-shadow-button",
text: ""
}, },
layout: { layout: {
flex: "flex-1 text-xl", flex: "flex-1 text-xl",
pad: "px-8 text-xl", pad: "px-8 text-xl",
small: "px-4 py-2 w-auto text-lg", small: "px-4 py-2 w-auto text-lg",
xs: "px-4 py-2 w-auto rounded-lg text-base" xs: "px-4 py-2 w-auto rounded-lg text-base",
full: "w-full text-xl"
} }
}, },
defaultVariants: { defaultVariants: {

View File

@@ -320,10 +320,15 @@ export function Checkbox(props: {
label: string; label: string;
checked: boolean; checked: boolean;
onChange: (checked: boolean) => void; onChange: (checked: boolean) => void;
caption?: string;
}) { }) {
return ( return (
<KCheckbox.Root <KCheckbox.Root
class="inline-flex items-center gap-2" class="inline-flex gap-2"
classList={{
"items-center": !props.caption,
"items-start": !!props.caption
}}
checked={props.checked} checked={props.checked}
onChange={props.onChange} onChange={props.onChange}
> >
@@ -333,8 +338,11 @@ export function Checkbox(props: {
<img src={check} class="w-8 h-8" alt="check" /> <img src={check} class="w-8 h-8" alt="check" />
</KCheckbox.Indicator> </KCheckbox.Indicator>
</KCheckbox.Control> </KCheckbox.Control>
<KCheckbox.Label class="flex-1 text-xl font-light"> <KCheckbox.Label class="flex-1 text-xl font-light flex flex-col gap-1">
{props.label} {props.label}
<Show when={props.caption}>
<TinyText>{props.caption}</TinyText>
</Show>
</KCheckbox.Label> </KCheckbox.Label>
</KCheckbox.Root> </KCheckbox.Root>
); );

334
src/routes/Feedback.tsx Normal file
View File

@@ -0,0 +1,334 @@
import {
SubmitHandler,
createForm,
email,
getValue,
required,
setValue
} from "@modular-forms/solid";
import { Match, Show, Switch, createSignal } from "solid-js";
import { A, useLocation } from "solid-start";
import NavBar from "~/components/NavBar";
import {
Button,
ButtonLink,
Checkbox,
DefaultMain,
LargeHeader,
NiceP,
SafeArea,
VStack
} from "~/components/layout";
import { BackPop } from "~/components/layout/BackPop";
import { ExternalLink } from "~/components/layout/ExternalLink";
import { StyledRadioGroup } from "~/components/layout/Radio";
import { TextField } from "~/components/layout/TextField";
import feedback from "~/assets/icons/feedback.svg";
import { InfoBox } from "~/components/InfoBox";
import eify from "~/utils/eify";
import { MegaCheck } from "~/components/successfail/MegaCheck";
const FEEDBACK_API = import.meta.env.VITE_FEEDBACK;
export function FeedbackLink(props: { setupError?: boolean }) {
const location = useLocation();
return (
<A
class="font-semibold no-underline text-m-grey-350 flex gap-2 items-center"
state={{
previous: location.pathname,
// If we're coming from an error page we want to know that so we can hide the navbar
// TODO: either use actual error info from this or remove and just check for setup error in the navbar component
setupError: props.setupError
}}
href="/feedback"
>
Feedback?
<img src={feedback} class="h-5 w-5" alt="Feedback" />
</A>
);
}
type FeedbackForm = {
include_contact: boolean;
user_type: "nostr" | "email";
id: string;
feedback: string;
include_logs: boolean;
images: File[];
};
const COMMUNICATION_METHODS = [
{ value: "nostr", label: "Nostr", caption: "Your freshest npub" },
{ value: "email", label: "Email", caption: "Burners welcome" }
];
async function formDataFromFeedbackForm(f: FeedbackForm) {
const formData = new FormData();
formData.append("description", JSON.stringify(f.feedback));
if (f.id) {
const contact =
f.user_type === "nostr" ? { nostr: f.id } : { email: f.id };
formData.append("contact", JSON.stringify(contact));
}
// TODO: add back logs and image uploads
// if (f.include_logs) {
// const logs = await MutinyWallet.get_logs();
// console.log(logs);
// // create a blob
// const blob = new Blob([logs], { type: "text/plain" });
// // add it to the form data
// formData.append("log", blob);
// }
// if (f.images.length > 0) {
// for (const image of f.images) {
// formData.append("image", image);
// }
// }
if (f.include_contact) {
const contact =
f.user_type === "nostr"
? { nostr: JSON.stringify(f.id) }
: { email: JSON.stringify(f.id) };
formData.append("contact", JSON.stringify(contact));
formData.append("feedback_type", JSON.stringify("generalcomment"));
}
return formData;
}
function FeedbackForm(props: { onSubmitted: () => void }) {
const [loading, setLoading] = createSignal(false);
const [error, setError] = createSignal<Error>();
const [feedbackForm, { Form, Field }] = createForm<FeedbackForm>({
initialValues: {
user_type: "nostr",
id: "",
feedback: "",
include_logs: false,
images: []
}
});
const handleSubmit: SubmitHandler<FeedbackForm> = async (
f: FeedbackForm
) => {
try {
setLoading(true);
const formData = await formDataFromFeedbackForm(f);
const res = await fetch(`${FEEDBACK_API}/v1/feedback`, {
method: "POST",
body: formData
});
if (!res.ok) {
throw new Error(`Error submitting feedback: ${res.statusText}`);
}
const json = await res.json();
if (json.status === "OK") {
props.onSubmitted();
} else {
throw new Error(
"Error submitting feedback. Please try again later."
);
}
} catch (e) {
console.error(e);
setError(eify(e));
} finally {
setLoading(false);
}
};
return (
<Form onSubmit={handleSubmit}>
<VStack>
<Field
name="feedback"
validate={[required("Please say something!")]}
>
{(field, props) => (
<TextField
multiline
{...props}
value={field.value}
error={field.error}
placeholder="Bugs, feature requests, feedback, etc."
/>
)}
</Field>
<Field name="include_contact" type="boolean">
{(field, _props) => (
<Checkbox
checked={field.value || false}
label="Include contact info"
caption="If you need us to follow-up on this issue"
onChange={(c) =>
setValue(feedbackForm, "include_contact", c)
}
/>
)}
</Field>
<Show when={getValue(feedbackForm, "include_contact") === true}>
<Field name="user_type">
{(field, _props) => (
// TODO: there's probably a "real" way to do this with modular-forms
<StyledRadioGroup
value={field.value || "nostr"}
onValueChange={(newValue) =>
setValue(
feedbackForm,
"user_type",
newValue as "nostr" | "email"
)
}
choices={COMMUNICATION_METHODS}
/>
)}
</Field>
<Switch>
<Match
when={
getValue(feedbackForm, "user_type", {
shouldActive: false
}) === "nostr"
}
>
<Field
name="id"
validate={[
required("We need some way to contact you")
]}
>
{(field, props) => (
<TextField
{...props}
value={field.value}
error={field.error}
label="Nostr npub or NIP-05"
placeholder="npub..."
/>
)}
</Field>
</Match>
<Match
when={
getValue(feedbackForm, "user_type", {
shouldActive: false
}) === "email"
}
>
<Field
name="id"
validate={[
required("We need some way to contact you"),
email(
"That doesn't look like an email address to me"
)
]}
>
{(field, props) => (
<TextField
{...props}
value={field.value}
error={field.error}
type="email"
label="Email"
placeholder="email@nokycemail.com"
/>
)}
</Field>
</Match>
</Switch>
</Show>
<Show when={error()}>
<InfoBox accent="red">{error()?.message}</InfoBox>
</Show>
<Button
loading={loading()}
disabled={
!feedbackForm.dirty ||
feedbackForm.submitting ||
feedbackForm.invalid
}
intent="blue"
type="submit"
>
Send Feedback
</Button>
</VStack>
</Form>
);
}
export default function Feedback() {
const [submitted, setSubmitted] = createSignal(false);
const location = useLocation();
const state = location.state as { setupError?: boolean };
const setupError = state?.setupError || undefined;
return (
<SafeArea>
<DefaultMain>
<BackPop />
<Switch>
<Match when={submitted()}>
<div class="flex flex-col gap-4 items-center h-full">
<MegaCheck />
<LargeHeader centered>
Feedback received!
</LargeHeader>
<NiceP>
Thank you for letting us know what's going on.
</NiceP>
<ButtonLink intent="blue" href="/" layout="full">
Go Home
</ButtonLink>
<Button
intent="text"
layout="full"
onClick={() => setSubmitted(false)}
>
Got more to say?
</Button>
</div>
</Match>
<Match when={true}>
<LargeHeader>Give us feedback!</LargeHeader>
<NiceP>
Mutiny doesn't track or spy on your behavior, so
your feedback is incredibly helpful.
</NiceP>
<NiceP>
If you're comfortable with GitHub you can also{" "}
<ExternalLink href="https://github.com/MutinyWallet/mutiny-web/issues">
create an issue
</ExternalLink>
.
</NiceP>
<FeedbackForm onSubmitted={() => setSubmitted(true)} />
</Match>
</Switch>
</DefaultMain>
<Show when={!setupError}>
<NavBar activeTab="send" />
</Show>
</SafeArea>
);
}

View File

@@ -47,6 +47,7 @@ import { ParsedParams, toParsedParams } from "~/logic/waila";
import { FeesModal } from "~/components/MoreInfoModal"; import { FeesModal } from "~/components/MoreInfoModal";
import { Clipboard } from "@capacitor/clipboard"; import { Clipboard } from "@capacitor/clipboard";
import { Capacitor } from "@capacitor/core"; import { Capacitor } from "@capacitor/core";
import { FeedbackLink } from "./Feedback";
export type SendSource = "lightning" | "onchain"; export type SendSource = "lightning" | "onchain";
@@ -716,6 +717,7 @@ export default function Send() {
{sending() ? "Sending..." : "Confirm Send"} {sending() ? "Sending..." : "Confirm Send"}
</Button> </Button>
</Show> </Show>
<FeedbackLink />
</VStack> </VStack>
</DefaultMain> </DefaultMain>
<NavBar activeTab="send" /> <NavBar activeTab="send" />

View File

@@ -30,6 +30,7 @@ module.exports = {
"m-red": "hsla(343, 92%, 54%, 1)", "m-red": "hsla(343, 92%, 54%, 1)",
"m-red-dark": "hsla(343, 92%, 44%, 1)", "m-red-dark": "hsla(343, 92%, 44%, 1)",
"sidebar-gray": "hsla(222, 15%, 7%, 1)", "sidebar-gray": "hsla(222, 15%, 7%, 1)",
"m-grey-350": "hsla(0, 0%, 73%, 1)",
"m-grey-400": "hsla(0, 0%, 64%, 1)", "m-grey-400": "hsla(0, 0%, 64%, 1)",
"m-grey-700": "hsla(0, 0%, 25%, 1)", "m-grey-700": "hsla(0, 0%, 25%, 1)",
"m-grey-750": "hsla(0, 0%, 17%, 1)", "m-grey-750": "hsla(0, 0%, 17%, 1)",