Survey notices for Servers (#3514)

* Survey notices for Servers

* lint

* remove creepy frog
This commit is contained in:
Prospector 2025-04-15 16:29:50 -07:00 committed by GitHub
parent 76be502e16
commit 6aa6db4e8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 168 additions and 38 deletions

View File

@ -6,29 +6,6 @@
}}</span> }}</span>
</template> </template>
<div class="flex w-[700px] flex-col gap-3"> <div class="flex w-[700px] flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="notice-title" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Title </span>
</label>
<input
id="notice-title"
v-model="newNoticeTitle"
placeholder="E.g. Maintenance"
type="text"
autocomplete="off"
/>
</div>
<div class="flex flex-col gap-2">
<label for="notice-message" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Message
<span class="text-brand-red">*</span>
</span>
</label>
<div class="textarea-wrapper h-32">
<textarea id="notice-message" v-model="newNoticeMessage" />
</div>
</div>
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<label for="level-selector" class="flex flex-col gap-1"> <label for="level-selector" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Level </span> <span class="text-lg font-semibold text-contrast"> Level </span>
@ -43,7 +20,38 @@
name="Level" name="Level"
/> />
</div> </div>
<div class="flex items-center justify-between gap-2"> <div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
<label for="notice-title" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Title </span>
</label>
<input
id="notice-title"
v-model="newNoticeTitle"
placeholder="E.g. Maintenance"
type="text"
autocomplete="off"
/>
</div>
<div class="flex flex-col gap-2">
<label for="notice-message" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
{{ newNoticeSurvey ? "Survey ID" : "Message" }}
<span class="text-brand-red">*</span>
</span>
</label>
<input
v-if="newNoticeSurvey"
id="notice-message"
v-model="newNoticeMessage"
placeholder="E.g. rXGtq2"
type="text"
autocomplete="off"
/>
<div v-else class="textarea-wrapper h-32">
<textarea id="notice-message" v-model="newNoticeMessage" />
</div>
</div>
<div v-if="!newNoticeSurvey" class="flex items-center justify-between gap-2">
<label for="dismissable-toggle" class="flex flex-col gap-1"> <label for="dismissable-toggle" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast"> Dismissable </span> <span class="text-lg font-semibold text-contrast"> Dismissable </span>
<span>Allow users to dismiss the notice from their panel.</span> <span>Allow users to dismiss the notice from their panel.</span>
@ -75,7 +83,7 @@
/> />
</div> </div>
<div class="flex flex-col gap-2"> <div v-if="!newNoticeSurvey" class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast"> Preview </span> <span class="text-lg font-semibold text-contrast"> Preview </span>
<ServerNotice <ServerNotice
:level="newNoticeLevel.id" :level="newNoticeLevel.id"
@ -355,6 +363,7 @@ async function deleteNotice(notice: ServerNoticeType) {
const trimmedMessage = computed(() => newNoticeMessage.value?.trim()); const trimmedMessage = computed(() => newNoticeMessage.value?.trim());
const trimmedTitle = computed(() => newNoticeTitle.value?.trim()); const trimmedTitle = computed(() => newNoticeTitle.value?.trim());
const newNoticeSurvey = computed(() => newNoticeLevel.value.id === "survey");
const noticeSubmitError = computed(() => { const noticeSubmitError = computed(() => {
let error: undefined | string; let error: undefined | string;
@ -389,9 +398,9 @@ async function saveChanges() {
method: "PATCH", method: "PATCH",
body: { body: {
message: newNoticeMessage.value, message: newNoticeMessage.value,
title: trimmedTitle.value, title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id, level: newNoticeLevel.value.id,
dismissable: newNoticeDismissable.value, dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
announce_at: newNoticeScheduledDate.value announce_at: newNoticeScheduledDate.value
? dayjs(newNoticeScheduledDate.value).toISOString() ? dayjs(newNoticeScheduledDate.value).toISOString()
: dayjs().toISOString(), : dayjs().toISOString(),
@ -420,9 +429,9 @@ async function createNotice() {
method: "POST", method: "POST",
body: { body: {
message: newNoticeMessage.value, message: newNoticeMessage.value,
title: trimmedTitle.value, title: newNoticeSurvey.value ? undefined : trimmedTitle.value,
level: newNoticeLevel.value.id, level: newNoticeLevel.value.id,
dismissable: newNoticeDismissable.value, dismissable: newNoticeSurvey.value ? true : newNoticeDismissable.value,
announce_at: newNoticeScheduledDate.value announce_at: newNoticeScheduledDate.value
? dayjs(newNoticeScheduledDate.value).toISOString() ? dayjs(newNoticeScheduledDate.value).toISOString()
: dayjs().toISOString(), : dayjs().toISOString(),

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="contents"> <div class="contents">
<div <div
v-if="serverData?.notices && serverData.notices.length > 0" v-if="filteredNotices.length > 0"
class="experimental-styles-within relative mx-auto flex w-full min-w-0 max-w-[1280px] flex-col gap-3 px-6" class="experimental-styles-within relative mx-auto flex w-full min-w-0 max-w-[1280px] flex-col gap-3 px-6"
> >
<ServerNotice <ServerNotice
v-for="notice in serverData?.notices" v-for="notice in filteredNotices"
:key="`notice-${notice.id}`" :key="`notice-${notice.id}`"
:level="notice.level" :level="notice.level"
:message="notice.message" :message="notice.message"
@ -430,15 +430,13 @@ const isFirstMount = ref(true);
const isMounted = ref(true); const isMounted = ref(true);
const INTERCOM_APP_ID = ref("ykeritl9"); const INTERCOM_APP_ID = ref("ykeritl9");
const auth = await useAuth(); const auth = (await useAuth()) as unknown as {
// @ts-expect-error - Auth is untyped value: { user: { id: string; username: string; email: string; created: string } };
};
const userId = ref(auth.value?.user?.id ?? null); const userId = ref(auth.value?.user?.id ?? null);
// @ts-expect-error - Auth is untyped
const username = ref(auth.value?.user?.username ?? null); const username = ref(auth.value?.user?.username ?? null);
// @ts-expect-error - Auth is untyped
const email = ref(auth.value?.user?.email ?? null); const email = ref(auth.value?.user?.email ?? null);
const createdAt = ref( const createdAt = ref(
// @ts-expect-error - Auth is untyped
auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null, auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null,
); );
@ -544,6 +542,99 @@ const navLinks = [
}, },
]; ];
const filteredNotices = computed(
() => serverData.value?.notices?.filter((n) => n.level !== "survey") ?? [],
);
const surveyNotice = computed(() => serverData.value?.notices?.find((n) => n.level === "survey"));
async function dismissSurvey() {
const noticeId = surveyNotice.value?.id;
if (noticeId === undefined) {
console.warn("No survey notice to dismiss");
return;
}
await dismissNotice(noticeId);
console.log(`Dismissed survey notice ${noticeId}`);
}
type TallyPopupOptions = {
key?: string;
layout?: "default" | "modal";
width?: number;
alignLeft?: boolean;
hideTitle?: boolean;
overlay?: boolean;
emoji?: {
text: string;
animation:
| "none"
| "wave"
| "tada"
| "heart-beat"
| "spin"
| "flash"
| "bounce"
| "rubber-band"
| "head-shake";
};
autoClose?: number;
showOnce?: boolean;
doNotShowAfterSubmit?: boolean;
customFormUrl?: string;
hiddenFields?: {
[key: string]: unknown;
};
onOpen?: () => void;
onClose?: () => void;
onPageView?: (page: number) => void;
onSubmit?: (payload: unknown) => void;
};
const popupOptions = computed(
() =>
({
layout: "default",
width: 400,
autoClose: 2000,
hideTitle: true,
hiddenFields: {
username: auth.value?.user?.username,
user_id: auth.value?.user?.id,
user_email: auth.value?.user?.email,
server_id: serverData.value?.server_id,
loader: serverData.value?.loader,
game_version: serverData.value?.mc_version,
modpack_id: serverData.value?.project?.id,
modpack_name: serverData.value?.project?.title,
},
onOpen: () => console.log(`Opened survey notice: ${surveyNotice.value?.id}`),
onClose: async () => await dismissSurvey(),
onSubmit: (payload: any) => {
console.log("Form submitted:", payload);
},
}) satisfies TallyPopupOptions,
);
function showSurvey() {
if (!surveyNotice.value) {
console.warn("No survey notice to open");
return;
}
try {
if ((window as any).Tally?.openPopup) {
console.log(
`Opening Tally popup for survey notice ${surveyNotice.value?.id} (form ID: ${surveyNotice.value?.message})`,
);
(window as any).Tally.openPopup(surveyNotice.value?.message, popupOptions.value);
} else {
console.warn("Tally script not yet loaded");
}
} catch (e) {
console.error("Error opening Tally popup:", e);
}
}
const connectWebSocket = () => { const connectWebSocket = () => {
if (!isMounted.value) return; if (!isMounted.value) return;
@ -1006,6 +1097,10 @@ onMounted(() => {
} }
}, },
); );
if (surveyNotice.value) {
showSurvey();
}
}); });
onUnmounted(() => { onUnmounted(() => {
@ -1030,6 +1125,15 @@ watch(
definePageMeta({ definePageMeta({
middleware: "auth", middleware: "auth",
}); });
useHead({
script: [
{
src: "https://tally.so/widgets/embed.js",
defer: true,
},
],
});
</script> </script>
<style> <style>

View File

@ -1,5 +1,11 @@
<template> <template>
<Admonition :type="NOTICE_TYPE[props.level]"> <div
v-if="level === 'survey'"
class="flex items-center gap-2 border-2 border-solid border-brand-purple bg-bg-purple p-4 rounded-2xl"
>
<span class="text-contrast font-bold">Survey ID:</span> <CopyCode :text="message" />
</div>
<Admonition v-else :type="NOTICE_TYPE[level]">
<template #header> <template #header>
<template v-if="!hideDefaultTitle"> <template v-if="!hideDefaultTitle">
{{ formatMessage(heading) }} {{ formatMessage(heading) }}
@ -32,6 +38,7 @@ import { XIcon } from '@modrinth/assets'
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl' import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
import { computed } from 'vue' import { computed } from 'vue'
import ButtonStyled from './ButtonStyled.vue' import ButtonStyled from './ButtonStyled.vue'
import CopyCode from './CopyCode.vue'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@ -34,6 +34,16 @@ export const NOTICE_LEVELS: Record<
bg: 'var(--color-red-bg)', bg: 'var(--color-red-bg)',
}, },
}, },
survey: {
name: defineMessage({
id: 'servers.notice.level.survey.name',
defaultMessage: 'Survey',
}),
colors: {
text: 'var(--color-purple)',
bg: 'var(--color-purple-bg)',
},
},
} }
const DISMISSABLE = { const DISMISSABLE = {

View File

@ -257,7 +257,7 @@ export type ServerNotice = {
id: number id: number
message: string message: string
title?: string title?: string
level: string level: 'info' | 'warn' | 'critical' | 'survey'
dismissable: boolean dismissable: boolean
announce_at: string announce_at: string
expires: string expires: string