Skip to content

Commit 386e6e5

Browse files
authored
refactor: move batch credit modal into default layout (#4767)
* refactor: move batch credit modal into default layout * fix: spacing + spans rather than p
1 parent 880ed21 commit 386e6e5

File tree

3 files changed

+267
-276
lines changed

3 files changed

+267
-276
lines changed
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
<template>
2+
<NewModal ref="modal">
3+
<template #title>
4+
<span class="text-lg font-extrabold text-contrast">Batch credit</span>
5+
</template>
6+
<div class="flex w-[500px] max-w-[90vw] flex-col gap-6">
7+
<div class="flex flex-col gap-2">
8+
<label class="flex flex-col gap-1">
9+
<span class="text-lg font-semibold text-contrast"> Type </span>
10+
<span>Select target to credit.</span>
11+
</label>
12+
<Combobox
13+
v-model="mode"
14+
:options="modeOptions"
15+
placeholder="Select type"
16+
class="max-w-[8rem]"
17+
/>
18+
</div>
19+
<div class="flex flex-col gap-2">
20+
<label for="days" class="flex flex-col gap-1">
21+
<span class="text-lg font-semibold text-contrast"> Days to credit </span>
22+
</label>
23+
<input
24+
id="days"
25+
v-model.number="days"
26+
class="w-32"
27+
type="number"
28+
min="1"
29+
autocomplete="off"
30+
/>
31+
</div>
32+
33+
<div v-if="mode === 'nodes'" class="flex flex-col gap-3">
34+
<div class="flex flex-col gap-2">
35+
<label for="node-input" class="flex flex-col gap-1">
36+
<span class="text-lg font-semibold text-contrast"> Node hostnames </span>
37+
</label>
38+
<div class="flex items-center gap-2">
39+
<input
40+
id="node-input"
41+
v-model="nodeInput"
42+
class="w-32"
43+
type="text"
44+
autocomplete="off"
45+
/>
46+
<ButtonStyled color="blue" color-fill="text">
47+
<button class="shrink-0" @click="addNode">
48+
<PlusIcon />
49+
Add
50+
</button>
51+
</ButtonStyled>
52+
</div>
53+
<div v-if="selectedNodes.length" class="mt-1 flex flex-wrap gap-2">
54+
<TagItem v-for="h in selectedNodes" :key="`node-${h}`" :action="() => removeNode(h)">
55+
<XIcon />
56+
{{ h }}
57+
</TagItem>
58+
</div>
59+
</div>
60+
</div>
61+
62+
<div v-else class="flex flex-col gap-3">
63+
<div class="flex flex-col gap-2">
64+
<label for="region-select" class="flex flex-col gap-1">
65+
<span class="text-lg font-semibold text-contrast"> Region </span>
66+
<span>This will credit all active servers in the region.</span>
67+
</label>
68+
<Combobox
69+
v-model="selectedRegion"
70+
:options="regions"
71+
placeholder="Select region"
72+
class="max-w-[24rem]"
73+
/>
74+
</div>
75+
</div>
76+
77+
<div class="between flex items-center gap-4">
78+
<label for="send-email-batch" class="flex flex-col gap-1">
79+
<span class="text-lg font-semibold text-contrast"> Send email </span>
80+
</label>
81+
<Toggle id="send-email-batch" v-model="sendEmail" />
82+
</div>
83+
84+
<div v-if="sendEmail" class="flex flex-col gap-2">
85+
<label for="message-batch" class="flex flex-col gap-1">
86+
<span class="text-lg font-semibold text-contrast"> Customize Email </span>
87+
<span>
88+
Unless a particularly bad or out of the ordinary event happened, keep this to the
89+
default
90+
</span>
91+
</label>
92+
<div
93+
class="text-muted flex flex-col gap-2 rounded-lg border border-divider bg-button-bg p-4"
94+
>
95+
<span>Hi {user.name},</span>
96+
<div class="textarea-wrapper">
97+
<textarea
98+
id="message-batch"
99+
v-model="message"
100+
rows="3"
101+
class="w-full overflow-hidden !bg-surface-3"
102+
/>
103+
</div>
104+
<span>
105+
To make up for it, we've added {{ days }} day{{ pluralize(days) }} to your Modrinth
106+
Servers subscription.
107+
</span>
108+
<span>
109+
Your next charge was scheduled for {credit.previous_due} and will now be on
110+
{credit.next_due}.
111+
</span>
112+
</div>
113+
</div>
114+
115+
<div class="flex gap-2">
116+
<ButtonStyled color="brand">
117+
<button :disabled="applyDisabled" @click="apply">
118+
<CheckIcon aria-hidden="true" />
119+
Apply credits
120+
</button>
121+
</ButtonStyled>
122+
<ButtonStyled>
123+
<button @click="modal?.hide?.()">
124+
<XIcon aria-hidden="true" />
125+
Cancel
126+
</button>
127+
</ButtonStyled>
128+
</div>
129+
</div>
130+
</NewModal>
131+
</template>
132+
133+
<script setup lang="ts">
134+
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
135+
import {
136+
ButtonStyled,
137+
Combobox,
138+
injectNotificationManager,
139+
NewModal,
140+
TagItem,
141+
Toggle,
142+
} from '@modrinth/ui'
143+
import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
144+
import { computed, ref } from 'vue'
145+
146+
import { useBaseFetch } from '#imports'
147+
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
148+
149+
const { addNotification } = injectNotificationManager()
150+
151+
const modal = ref<InstanceType<typeof NewModal>>()
152+
153+
const days = ref(1)
154+
const sendEmail = ref(true)
155+
const message = ref('')
156+
157+
const modeOptions = [
158+
{ value: 'nodes', label: 'Nodes' },
159+
{ value: 'region', label: 'Region' },
160+
]
161+
const mode = ref<string>('nodes')
162+
163+
const nodeInput = ref('')
164+
const selectedNodes = ref<string[]>([])
165+
166+
type RegionOpt = { value: string; label: string }
167+
const regions = ref<RegionOpt[]>([])
168+
const selectedRegion = ref<string | null>(null)
169+
const nodeHostnames = ref<string[]>([])
170+
171+
function show(event?: Event) {
172+
void ensureOverview()
173+
message.value = DEFAULT_CREDIT_EMAIL_MESSAGE
174+
modal.value?.show(event)
175+
}
176+
177+
function hide() {
178+
modal.value?.hide()
179+
}
180+
181+
function addNode() {
182+
const v = nodeInput.value.trim()
183+
if (!v) return
184+
if (!nodeHostnames.value.includes(v)) {
185+
addNotification({
186+
title: 'Unknown node',
187+
text: "This hostname doesn't exist",
188+
type: 'error',
189+
})
190+
return
191+
}
192+
if (!selectedNodes.value.includes(v)) selectedNodes.value.push(v)
193+
nodeInput.value = ''
194+
}
195+
196+
function removeNode(v: string) {
197+
selectedNodes.value = selectedNodes.value.filter((x) => x !== v)
198+
}
199+
200+
const applyDisabled = computed(() => {
201+
if (days.value < 1) return true
202+
if (mode.value === 'nodes') return selectedNodes.value.length === 0
203+
return !selectedRegion.value
204+
})
205+
206+
async function ensureOverview() {
207+
if (regions.value.length || nodeHostnames.value.length) return
208+
try {
209+
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
210+
regions.value = (data.regions || []).map((r: any) => ({
211+
value: r.key,
212+
label: `${r.display_name} (${r.key})`,
213+
}))
214+
nodeHostnames.value = data.node_hostnames || []
215+
if (!selectedRegion.value && regions.value.length) selectedRegion.value = regions.value[0].value
216+
} catch (err) {
217+
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
218+
}
219+
}
220+
221+
async function apply() {
222+
try {
223+
const body =
224+
mode.value === 'nodes'
225+
? {
226+
nodes: selectedNodes.value.slice(),
227+
days: Math.max(1, Math.floor(days.value)),
228+
send_email: sendEmail.value,
229+
message: message.value?.trim() || DEFAULT_CREDIT_EMAIL_MESSAGE,
230+
}
231+
: {
232+
region: selectedRegion.value!,
233+
days: Math.max(1, Math.floor(days.value)),
234+
send_email: sendEmail.value,
235+
message: message.value?.trim() || DEFAULT_CREDIT_EMAIL_MESSAGE,
236+
}
237+
await useBaseFetch('billing/credit', {
238+
method: 'POST',
239+
body: JSON.stringify(body),
240+
internal: true,
241+
})
242+
addNotification({ title: 'Credits applied', type: 'success' })
243+
modal.value?.hide()
244+
selectedNodes.value = []
245+
nodeInput.value = ''
246+
message.value = ''
247+
} catch (err: any) {
248+
addNotification({
249+
title: 'Error applying credits',
250+
text: err?.data?.description ?? String(err),
251+
type: 'error',
252+
})
253+
}
254+
}
255+
256+
function pluralize(n: number): string {
257+
return n === 1 ? '' : 's'
258+
}
259+
260+
defineExpose({
261+
show,
262+
hide,
263+
})
264+
</script>

apps/frontend/src/layouts/default.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@
477477
{
478478
id: 'servers-nodes',
479479
color: 'primary',
480-
link: '/admin/servers/nodes',
480+
action: (event) => $refs.modal_batch_credit.show(event),
481481
shown: isAdmin(auth.user),
482482
},
483483
]"
@@ -786,6 +786,7 @@
786786
<ProjectCreateModal v-if="auth.user" ref="modal_creation" />
787787
<CollectionCreateModal ref="modal_collection_creation" />
788788
<OrganizationCreateModal ref="modal_organization_creation" />
789+
<BatchCreditModal v-if="auth.user && isAdmin(auth.user)" ref="modal_batch_credit" />
789790
<slot id="main" />
790791
</main>
791792
<footer
@@ -937,6 +938,7 @@ import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
937938
import { IntlFormatted } from '@vintl/vintl/components'
938939
939940
import TextLogo from '~/components/brand/TextLogo.vue'
941+
import BatchCreditModal from '~/components/ui/admin/BatchCreditModal.vue'
940942
import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.vue'
941943
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
942944
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'

0 commit comments

Comments
 (0)