mirror of
https://github.com/aljazceru/mutiny-web.git
synced 2025-12-24 01:24:28 +01:00
add feedback form
This commit is contained in:
@@ -15,3 +15,4 @@ VITE_SELFHOSTED="true"
|
||||
VITE_AUTH="https://auth-staging.mutinywallet.com"
|
||||
VITE_SUBSCRIPTIONS="https://subscriptions-staging.mutinywallet.com"
|
||||
VITE_STORAGE="https://storage-staging.mutinywallet.com"
|
||||
VITE_FEEDBACK="https://feedback-staging.mutinywallet.com"
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
"@capacitor/core": "^5.2.1",
|
||||
"@kobalte/core": "^0.9.8",
|
||||
"@kobalte/tailwindcss": "^0.5.0",
|
||||
"@modular-forms/solid": "^0.13.2",
|
||||
"@mutinywallet/mutiny-wasm": "0.4.4",
|
||||
"@modular-forms/solid": "^0.18.0",
|
||||
"@mutinywallet/waila-wasm": "^0.2.1",
|
||||
"@solid-primitives/upload": "^0.0.111",
|
||||
"@solidjs/meta": "^0.28.5",
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -1,5 +1,9 @@
|
||||
lockfileVersion: '6.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
dependencies:
|
||||
'@capacitor/android':
|
||||
specifier: ^5.2.1
|
||||
@@ -17,8 +21,8 @@ dependencies:
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0(tailwindcss@3.3.2)
|
||||
'@modular-forms/solid':
|
||||
specifier: ^0.13.2
|
||||
version: 0.13.2(solid-js@1.7.7)
|
||||
specifier: ^0.18.0
|
||||
version: 0.18.0(solid-js@1.7.7)
|
||||
'@mutinywallet/barcode-scanner':
|
||||
specifier: 5.0.0-beta.3
|
||||
version: 5.0.0-beta.3(@capacitor/core@5.2.1)
|
||||
@@ -2093,8 +2097,8 @@ packages:
|
||||
solid-js: 1.7.7
|
||||
dev: false
|
||||
|
||||
/@modular-forms/solid@0.13.2(solid-js@1.7.7):
|
||||
resolution: {integrity: sha512-wiwJUxbwDp8F2FI0hTYoydNumVRZruIiyjV+w91++zA0QofASuMWe3fs4lETHpaiQK/jJsGXhys7BFqXUf9SDg==}
|
||||
/@modular-forms/solid@0.18.0(solid-js@1.7.7):
|
||||
resolution: {integrity: sha512-0+MuHb8wIE5GPazZZqqaDop85w8w/DUBOxreYFx4o2/4jzVTlV1z/913EqPssxMYKJCG0rQSEVERKmJX3af3zw==}
|
||||
peerDependencies:
|
||||
solid-js: ^1.3.1
|
||||
dependencies:
|
||||
@@ -8023,7 +8027,3 @@ packages:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
dev: true
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
3
src/assets/icons/feedback.svg
Normal file
3
src/assets/icons/feedback.svg
Normal 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 |
@@ -7,19 +7,17 @@ import { OnboardWarning } from "~/components/OnboardWarning";
|
||||
import { CombinedActivity } from "./Activity";
|
||||
import { useMegaStore } from "~/state/megaStore";
|
||||
import { Match, Show, Suspense, Switch } from "solid-js";
|
||||
import { ExternalLink } from "./layout/ExternalLink";
|
||||
import { BetaWarningModal } from "~/components/BetaWarningModal";
|
||||
import settings from "~/assets/icons/settings.svg";
|
||||
import pixelLogo from "~/assets/mutiny-pixel-logo.png";
|
||||
import plusLogo from "~/assets/mutiny-plus-logo.png";
|
||||
import { PendingNwc } from "./PendingNwc";
|
||||
import { useI18n } from "~/i18n/context";
|
||||
import { DecryptDialog } from "./DecryptDialog";
|
||||
import { LoadingIndicator } from "./LoadingIndicator";
|
||||
import { FeedbackLink } from "~/routes/Feedback";
|
||||
|
||||
export default function App() {
|
||||
const [state, _actions] = useMegaStore();
|
||||
const i18n = useI18n();
|
||||
|
||||
return (
|
||||
<SafeArea>
|
||||
@@ -86,14 +84,9 @@ export default function App() {
|
||||
</Suspense>
|
||||
</VStack>
|
||||
</Card>
|
||||
<p class="self-center text-neutral-500 mt-4 font-normal">
|
||||
Bugs? Feedback?{" "}
|
||||
<span class="text-neutral-400">
|
||||
<ExternalLink href="https://github.com/MutinyWallet/mutiny-web/issues">
|
||||
{i18n.t("create_an_issue")}
|
||||
</ExternalLink>
|
||||
</span>
|
||||
</p>
|
||||
<div class="self-center mt-4">
|
||||
<FeedbackLink />
|
||||
</div>
|
||||
</DefaultMain>
|
||||
<DecryptDialog />
|
||||
<BetaWarningModal />
|
||||
|
||||
@@ -11,19 +11,15 @@ import { Match, Switch } from "solid-js";
|
||||
import { ImportExport } from "./ImportExport";
|
||||
import { Logs } from "./Logs";
|
||||
import { DeleteEverything } from "./DeleteEverything";
|
||||
import { FeedbackLink } from "~/routes/Feedback";
|
||||
|
||||
function ErrorFooter() {
|
||||
return (
|
||||
<>
|
||||
<div class="h-full" />
|
||||
<p class="self-center text-neutral-500 mt-4">
|
||||
Bugs? Feedback?{" "}
|
||||
<span class="text-neutral-400">
|
||||
<ExternalLink href="https://github.com/MutinyWallet/mutiny-web/issues">
|
||||
Create an issue
|
||||
</ExternalLink>
|
||||
</span>
|
||||
</p>
|
||||
<div class="self-center mt-4">
|
||||
<FeedbackLink setupError={true} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
23
src/components/layout/BackPop.tsx
Normal file
23
src/components/layout/BackPop.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
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",
|
||||
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: {
|
||||
flex: "flex-1 text-xl",
|
||||
pad: "px-8 text-xl",
|
||||
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: {
|
||||
|
||||
@@ -320,10 +320,15 @@ export function Checkbox(props: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
caption?: string;
|
||||
}) {
|
||||
return (
|
||||
<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}
|
||||
onChange={props.onChange}
|
||||
>
|
||||
@@ -333,8 +338,11 @@ export function Checkbox(props: {
|
||||
<img src={check} class="w-8 h-8" alt="check" />
|
||||
</KCheckbox.Indicator>
|
||||
</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}
|
||||
<Show when={props.caption}>
|
||||
<TinyText>{props.caption}</TinyText>
|
||||
</Show>
|
||||
</KCheckbox.Label>
|
||||
</KCheckbox.Root>
|
||||
);
|
||||
|
||||
334
src/routes/Feedback.tsx
Normal file
334
src/routes/Feedback.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { ParsedParams, toParsedParams } from "~/logic/waila";
|
||||
import { FeesModal } from "~/components/MoreInfoModal";
|
||||
import { Clipboard } from "@capacitor/clipboard";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import { FeedbackLink } from "./Feedback";
|
||||
|
||||
export type SendSource = "lightning" | "onchain";
|
||||
|
||||
@@ -716,6 +717,7 @@ export default function Send() {
|
||||
{sending() ? "Sending..." : "Confirm Send"}
|
||||
</Button>
|
||||
</Show>
|
||||
<FeedbackLink />
|
||||
</VStack>
|
||||
</DefaultMain>
|
||||
<NavBar activeTab="send" />
|
||||
|
||||
@@ -30,6 +30,7 @@ module.exports = {
|
||||
"m-red": "hsla(343, 92%, 54%, 1)",
|
||||
"m-red-dark": "hsla(343, 92%, 44%, 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-700": "hsla(0, 0%, 25%, 1)",
|
||||
"m-grey-750": "hsla(0, 0%, 17%, 1)",
|
||||
|
||||
Reference in New Issue
Block a user