feat: account pages
Some checks failed
Build and Publish Docker Image / Build and Publish (amd64) (push) Has been cancelled
Build and Publish Docker Image / Build and Publish (arm64) (push) Has been cancelled

This commit is contained in:
limes
2026-08-15 06:09:14 +02:00
parent 813397ece7
commit a9e578d8f4
11 changed files with 1430 additions and 122 deletions

View File

@@ -68,16 +68,18 @@ The website is currently in a refactor, here is what is still left on the fronte
- [x] Styling for progress page
- [x] Styling for progress on main page
- [X] Account: Mii editor
- [ ] Account: View account
- [ ] Account: Delete account
- [ ] Account: Link discord account
- [ ] Account: Edit server environment
- [ ] Donations: stripe checkout frontend
- [X] Account: View account
- [X] Account: Delete account
- [X] Account: Link discord account
- [X] Account: Edit server environment
- [ ] Account: Edit account info
- [X] Donations: stripe checkout frontend
- [ ] Auth: Login
- [ ] Auth: Logout
- [ ] Auth: Register
- [ ] Auth: Forgot password flow
- [ ] Content: Term pages
- [ ] April fools'
- [X] Content: Documentation pages
- [x] Content: Blog

20
package-lock.json generated
View File

@@ -14,7 +14,7 @@
"@nuxt/content": "^3.4.0",
"@nuxt/eslint": "^1.3.0",
"@nuxt/fonts": "^0.14.0",
"@nuxt/icon": "^2.4.1",
"@nuxt/icon": "^2.5.0",
"@nuxtjs/i18n": "^10.6.0",
"@pinia/nuxt": "^1.0.1",
"@pretendonetwork/error-codes": "^1.2.2",
@@ -1685,9 +1685,9 @@
}
},
"node_modules/@iconify/collections": {
"version": "1.0.719",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.719.tgz",
"integrity": "sha512-v3I7sBgR0UWOr6y4bXGiuNrOOl9d+tRDPg1d7vfQHofQ4nTt3AU15S2UsePjHztYAc7ptSPUTT74Io2M7Y1CZg==",
"version": "1.0.724",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.724.tgz",
"integrity": "sha512-wl26lugNV8a3z1bsJyNceAnGeOCrvkXdZmw1Yvs40qLlz90EzMYPYA2OLFITzC9ALx6Ar3vyzEKo7ZNNKw0zjg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
@@ -3281,17 +3281,17 @@
}
},
"node_modules/@nuxt/icon": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.4.1.tgz",
"integrity": "sha512-fOY3kiT6OoL4bWXVmMLc1VgoCm1PxIGSBmsSaLO090NEgtd1ub441dZ7MEoOD5UIilWzba1kCWdwJcAkGCOwLg==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.5.0.tgz",
"integrity": "sha512-EBczSfd3QmAD7GoSmJ5jCPvQ5zzNaPPuWPU9DSMcBuBM10A3JIvjaMLEOEniHQRz4iVPb05cJRO/JdC60oiiwg==",
"license": "MIT",
"dependencies": {
"@iconify/collections": "^1.0.715",
"@iconify/collections": "^1.0.721",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.4",
"@iconify/vue": "^5.0.1",
"@nuxt/devtools-kit": "^3.3.1",
"@nuxt/kit": "^4.5.0",
"@nuxt/devtools-kit": "^3.4.1",
"@nuxt/kit": "^4.5.2",
"consola": "^3.4.2",
"local-pkg": "^1.2.1",
"mlly": "^1.8.2",

View File

@@ -19,7 +19,7 @@
"@nuxt/content": "^3.4.0",
"@nuxt/eslint": "^1.3.0",
"@nuxt/fonts": "^0.14.0",
"@nuxt/icon": "^2.4.1",
"@nuxt/icon": "^2.5.0",
"@nuxtjs/i18n": "^10.6.0",
"@pinia/nuxt": "^1.0.1",
"@pretendonetwork/error-codes": "^1.2.2",

View File

@@ -10,13 +10,19 @@ export default defineEventHandler(async (event): Promise<GetApiAuthMe> => {
pid: data.pid,
username: data.username,
accessLevel: data.accessLevel,
birthday: data.birthday,
gender: data.gender,
country: data.country,
timezone: data.timezone,
emailAddress: data.emailAddress,
serverAccessLevel: data.serverAccessLevel as any,
discordId: data.connections?.discord?.id ?? null,
stripeTier: data.connections?.stripe?.subscriptionId
? {
subscriptionId: data.connections.stripe.subscriptionId,
priceId: data.connections.stripe.priceId ?? '',
tierName: data.connections.stripe.tierName ?? ''
tierName: data.connections.stripe.tierName ?? '',
tierLevel: data.connections.stripe.tierLevel ?? ''
}
: null,
mii: data.mii

View File

@@ -4,12 +4,18 @@ export type GetApiAuthMe = {
pid: number;
username: string;
accessLevel: number;
birthday: string;
gender: string;
country: string;
timezone: string;
emailAddress: string;
serverAccessLevel: 'dev' | 'test' | 'prod';
discordId: string | null;
stripeTier: {
subscriptionId: string;
priceId: string;
tierName: string;
tierLevel: string | number;
} | null;
mii: {
imageUrl: string;

View File

@@ -140,7 +140,7 @@ div.modal-wrapper {
align-items: center;
background: rgba(0, 0, 0, 0.6);
z-index: 10;
z-index: 100;
}
div.modal-wrapper.hidden {
display: none;
@@ -152,34 +152,37 @@ div.modal {
text-align: left;
width: min(660px, 90%);
box-sizing: border-box;
margin: auto;
}
div.modal h1 {
div.modal h2 {
margin-top: 0;
}
p.modal-caption {
color: var(--text-shade-1);
}
p.modal-caption span,
p.switch-tier-modal-caption span {
color: var(--text-shade-3);
}
.modal-caption span span {
color: var(--text-shade-3);
font-weight: bold;
}
.modal-button-wrapper {
margin-top: 24px;
display: flex;
justify-content: flex-end;
}
.modal-button-wrapper button {
margin-left: 12px;
width: fit-content;
}
.modal-button-wrapper button.cancel {
background: none;
display: grid;
grid-auto-flow: column;
gap: 1em;
}
.modal-button-wrapper button {
padding: 12px 24px;
}
.modal-button-wrapper button.cancel {
background: var(--bg-shade-4);
}
.modal-button-wrapper button.alert {
background: var(--red-shade-1);
}
.modal-button-wrapper button.action {
background: var(--green-shade-0);
}
.purple-card {
border-radius: 10px;
@@ -270,6 +273,31 @@ input[type="range"]:focus::-moz-range-thumb {
background-color: var(--accent-shade-3);
}
.progress-bar {
position: relative;
display: block;
width: 100%;
height: 12px;
margin: 1rem 0;
border-radius: 6px;
background: var(--bg-shade-3);
}
.progress-bar-real,
.progress-bar-capped {
position: absolute;
height: 100%;
border-radius: 6px;
width: var(--progress-bar-width);
}
.progress-bar-capped {
background-color: var(--accent-shade-0);
max-width: 100%;
}
.progress-bar-real {
background-color: var(--green-shade-1);
width: calc(var(--progress-bar-width) - 6px);
left: 6px;
}
.wrapper {
width: 95%;
@@ -293,6 +321,10 @@ input[type="range"]:focus::-moz-range-thumb {
.hide-on-desktop {
display: unset !important;
}
.modal-button-wrapper {
grid-auto-flow: row;
}
}
/* Scrollbar styling 'cause it's fancy */

View File

@@ -1,15 +1,39 @@
<script setup lang="ts">
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger, ToastDescription, ToastProvider, ToastRoot, ToastViewport
} from 'reka-ui';
import type { ApiAccountUpdateRequest } from '~~/shared/api-types';
definePageMeta({
needsAuth: true
});
const upgradeSuccess = ref(useRoute().query.upgrade_success === 'true');
const upgradeError = ref(useRoute().query.upgrade_success === 'false');
const showToast = computed(() => upgradeSuccess.value || upgradeError.value);
if (showToast.value) {
useRouter().replace({ query: {} });
}
const authStore = useAuthStore();
const { data: profile, refresh } = await useApiFetch('/api/auth/me');
const { data: connections, refresh: refreshConnections } = await useApiFetch('/api/auth/me-connections');
async function updateServerEnvironment(env: ApiAccountUpdateRequest['environment']) {
const selectedServerEnv = ref(profile.value?.serverAccessLevel);
const dialogContainer = ref(null);
const modalIsOpen = ref(false);
async function updateServerEnvironment(
env: ApiAccountUpdateRequest['environment']
) {
try {
await apiFetch('/api/account/update', {
method: 'PATCH',
@@ -62,66 +86,738 @@ async function unlinkDiscord() {
}
}
async function updateMii() {
try {
await apiFetch('/api/account/update', {
method: 'PATCH',
body: {
mii: { name: 'steve', primary: 'Y', data: 'AwAAQOlVognnx0GC2qjhdwOzuI0n2QAAAGBzAHQAZQB2AGUAAAAAAAAAAAAAAEBAAAAhAQJoRBgmNEYUgRIXaA0AACkAUkhQAAAAAAAAAAAAAAAAAAAAAAAAAAAAANeC' }
} satisfies ApiAccountUpdateRequest
});
await refresh();
} catch (error: unknown) {
const err = getApiError(error);
alert(err.code);
}
}
useHead({
title: `Account`
});
</script>
<template>
<div v-if="profile">
<h1>Hello {{ profile.username }}</h1>
<p>TODO: account stub page</p>
<div :style="{ border: '1px solid white', margin: '2rem 0' }">
<p>Environment ({{ profile.serverAccessLevel }})</p>
<button @click="updateServerEnvironment('prod')">
Prod
</button>
<button @click="updateServerEnvironment('test')">
Test
</button>
<button @click="updateServerEnvironment('dev')">
Dev
</button>
</div>
<button
v-if="profile.discordId"
@click="unlinkDiscord()"
>
Unlink discord: <span v-if="connections?.discord">
<img
:style="{ height: '25px', width: '25px', borderRadius: '100px'}"
:src="connections.discord.avatarUrl ?? '#'"
<div
v-if="profile"
class="account-wrapper"
>
<div class="account-sidebar">
<div class="user">
<a
href="/account/miieditor"
class="mii"
>
{{ connections.discord.username }}
</span>
</button>
<button
v-else
@click="linkDiscord()"
<img
:src="profile.mii?.imageUrl"
alt="Mii image"
>
</a>
<p class="miiname">
{{ profile.mii?.name }}
</p>
<p
class="username"
:value="profile.username"
>
PNID: {{ profile.username }}
</p>
<p
v-if="profile.stripeTier?.tierName"
:class="`tier-name access-level-${profile.accessLevel}`"
:value="profile.stripeTier?.tierName"
>
{{ profile.stripeTier?.tierName }}
</p>
<p
v-else-if="profile.accessLevel !== -1"
:class="`tier-name access-level-${profile.accessLevel}`"
:value="$t(`account.accountLevel[${profile.accessLevel}]`)"
>
{{ $t(`account.accountLevel[${profile.accessLevel}]`) }}
</p>
<p
v-else
class="tier-name access-level-banned"
:value="$t('account.banned')"
>
{{ $t("account.banned") }}
</p>
</div>
<div class="buttons">
<NuxtLink
id="account-upgrade"
class="button secondary"
to="/account/upgrade"
>
<p class="caption">
{{ $t("account.settings.upgrade") }}
</p>
</NuxtLink>
<AlertDialogRoot v-model:open="modalIsOpen">
<AlertDialogTrigger
id="account-delete"
class="secondary"
>
{{ $t("account.settings.delete.button") }}
</AlertDialogTrigger>
<AlertDialogPortal :to="dialogContainer">
<AlertDialogOverlay />
<AlertDialogContent class="modal">
<AlertDialogTitle>{{ $t("account.settings.delete.modalTitle") }}?</AlertDialogTitle>
<AlertDialogDescription class="modal-caption">
<p
style="white-space: pre-line;"
>
{{ $t('account.settings.delete.modalDescription') }}
</p>
<p class="noundo">
{{ $t('account.settings.delete.modalCaution') }}
</p>
</AlertDialogDescription>
<div class="modal-button-wrapper">
<AlertDialogCancel class="cancel">
{{ $t("modals.cancel") }}
</AlertDialogCancel>
<AlertDialogAction
class="alert"
@click="deleteAccount"
>
{{ $t("account.settings.delete.modalConfirm") }}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</div>
<div class="settings-wrapper">
<h2
id="user-settings"
class="section-header"
>
{{ $t("account.settings.settingCards.userSettings") }}
</h2>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.profile") }}
</h2>
<a
href="/account/edit/profile"
class="edit"
>
<Icon
name="ph:pencil"
size="26"
/>
</a>
<ul class="setting-list">
<li>
<p class="label">
{{ $t("account.settings.settingCards.nickname") }}
</p>
<p class="value">
{{ profile.mii?.name }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.birthDate") }}
</p>
<p class="value">
{{ profile.birthday }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.gender") }}
</p>
<p class="value">
{{ profile.gender }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.country") }}
</p>
<p class="value">
{{ profile.country }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.timezone") }}
</p>
<p class="value">
{{ profile.timezone }}
</p>
</li>
</ul>
</div>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.serverEnv") }}
</h2>
<fieldset :disabled="profile.accessLevel < 1">
<form
id="server"
class="server-selection"
>
<input
id="prod"
v-model="selectedServerEnv"
type="radio"
value="prod"
>
<label for="prod">
<Icon
name="ph:cube"
size="36"
/>
<h2>{{ $t("account.settings.settingCards.production") }}</h2>
</label>
<input
id="test"
v-model="selectedServerEnv"
type="radio"
value="test"
>
<label for="test">
<Icon
name="ph:flask"
size="36"
/>
<h2>{{ $t("account.settings.settingCards.beta") }}</h2>
</label>
</form>
</fieldset>
<button
v-if="
profile.accessLevel >= 1 &&
selectedServerEnv !== profile.serverAccessLevel
"
id="save-server-selection"
class="button secondary"
@click.prevent="() => updateServerEnvironment(selectedServerEnv)"
>
Save
</button>
<p
v-html="
profile.accessLevel < 1
? $t('account.settings.settingCards.upgradePrompt')
: $t('account.settings.settingCards.hasAccessPrompt')
"
/>
</div>
<h2
id="security"
class="section-header"
>
{{ $t("account.settings.settingCards.signInSecurity") }}
</h2>
<div class="setting-card">
<h2 class="header">
{{ $t("account.account") }}
</h2>
<a
href="/account/edit/login-info"
class="edit"
>
<Icon
name="ph:pencil"
size="26"
/>
</a>
<ul class="setting-list">
<li>
<p class="label">
{{ $t("account.settings.settingCards.email") }}
</p>
<p class="value">
{{ profile.emailAddress }}
</p>
</li>
<li>
<p class="label">
{{ $t("account.settings.settingCards.password") }}
</p>
<p class="value">
●●●●●●●●
</p>
</li>
</ul>
<p>{{ $t("account.settings.settingCards.passwordResetNotice") }}</p>
</div>
<div class="setting-card sign-in-history">
<h2 class="header">
{{ $t("account.settings.settingCards.signInHistory") }}
</h2>
<p>{{ $t("account.settings.settingCards.no_signins_notice") }}</p>
</div>
<h2
id="other"
class="section-header"
>
{{ $t("account.settings.settingCards.otherSettings") }}
</h2>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.discord") }}
</h2>
<p
v-if="profile.discordId"
class="discord-profile"
>
{{ $t("account.settings.settingCards.connectedToDiscord") }}
<img
:style="{ height: '25px', width: '25px', borderRadius: '100px'}"
:src="connections?.discord?.avatarUrl ?? '#'"
>@{{
connections?.discord?.username
}}.
</p>
<button
v-if="profile.discordId"
id="remove-discord-connection"
class="button secondary"
@click="unlinkDiscord"
>
{{ $t("account.settings.settingCards.removeDiscord") }}
</button>
<p v-else>
{{ $t("account.settings.settingCards.noDiscordLinked") }}
<NuxtLink
:style="{cursor: 'pointer'}"
@click="linkDiscord"
>
{{ $t("account.settings.settingCards.linkDiscord") }}
</NuxtLink>
</p>
</div>
<div class="setting-card">
<h2 class="header">
{{ $t("account.settings.settingCards.newsletter") }}
</h2>
<p>{{ $t("account.settings.settingCards.no_newsletter_notice") }}</p>
<!--
<form id="other">
<input type="checkbox" id="marketing" name="marketing" {{#if account.flags.marketing}}checked{{/if}}>
<label for="marketing">{{ locale.account.settings.settingCards.newsletterPrompt }}</label>
</form>
-->
</div>
</div>
<div
id="delete-account"
:class="{ 'modal-wrapper': true, hidden: !modalIsOpen }"
>
Link discord
</button>
<button
@click="updateMii()"
<div ref="dialogContainer" />
</div>
<div
v-if="showToast"
:class="{'banner-notice': true, success: upgradeSuccess, error: upgradeError }"
>
Update Mii
</button>
<button
:style="{ color: 'red' }"
@click="deleteAccount()"
>
Delete account
</button>
<ToastProvider>
<ToastRoot as="div">
<ToastDescription
v-if="upgradeSuccess"
as="p"
>
Account upgraded successfully
</ToastDescription>
<ToastDescription
v-if="upgradeError"
as="p"
>
Account upgrade failed
</ToastDescription>
</ToastRoot>
<ToastViewport as="p" />
</ToastProvider>
</div>
</div>
</template>
<style scoped>
/* Removing until it's done */
.sign-in-history a {
display: none;
}
.account-wrapper {
display: grid;
column-gap: 48px;
margin-top: 80px;
color: var(--text-shade-1);
}
/* Account settings sidebar */
.account-sidebar .user {
margin: 55px auto auto;
width: fit-content;
display: flex;
flex-flow: column;
align-items: center;
}
.account-sidebar .user .miiname {
font-size: 1.2rem;
color: var(--text-shade-3);
margin: 8px 0 4px;
}
.account-sidebar .user .username {
margin: 0;
}
.account-sidebar .user .tier-name {
margin: 12px 0;
line-height: 1.2em;
border-radius: 1.2em;
border-width: 2px;
border-style: solid;
padding: 4px 16px;
}
.account-sidebar .user .tier-level-0,
.account-sidebar .user .access-level-0 {
background: #2a2f50;
color: var(--text-shade-1);
border-color: #383f6b;
}
.account-sidebar .user .tier-level-1 {
background: rgba(255, 132, 132, 0.2);
color: #ff8484;
border-color: rgba(255, 132, 132, 0.8);
}
.account-sidebar .user .tier-level-2 {
background: rgba(89, 201, 165, 0.3);
color: #59c9a5;
border-color: #59c9a5;
}
.account-sidebar .user .tier-level-3 {
background: rgba(202, 177, 251, 0.3);
color: var(--accent-shade-3);
border-color: var(--accent-shade-3);
}
.account-sidebar .user .access-level-banned {
background: rgba(255, 63, 0, 0.1);
color: #ff3f00;
border-color: rgba(255, 63, 0, 0.8);
}
.account-sidebar .user .access-level-1 {
background: rgba(100, 247, 239, 0.3);
color: #64f7ef;
border-color: #64f7ef;
}
.account-sidebar .user .access-level-2 {
background: rgba(255, 199, 89, 0.3);
color: #ffc759;
border-color: #ffc759;
}
.account-sidebar .user .access-level-3 {
background: rgba(90, 255, 21, 0.3);
color: #5aff15;
border-color: #5aff15;
}
.account-sidebar .user a.mii {
position: relative;
display: block;
width: 128px;
height: 128px;
overflow: hidden;
border-radius: 100%;
background: var(--bg-shade-3);
}
.account-sidebar .user a.mii::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background:
no-repeat center/50% url("@/public/assets/images/edit.svg"),
rgba(55, 60, 101, 0.7);
opacity: 0;
transition: opacity 150ms;
}
.account-sidebar .user a.mii:hover::after {
opacity: 1;
}
.account-sidebar .user .mii {
width: 100%;
height: 100%;
}
.account-sidebar .buttons {
display: grid;
grid-auto-flow: row;
gap: 16px;
}
.account-sidebar .buttons a {
display: flex;
flex-flow: column;
align-items: center;
margin: 20px 0 0 0;
text-decoration: none;
text-align: center;
}
.account-sidebar .buttons a svg {
margin-bottom: 16px;
}
.account-sidebar .buttons a p.caption {
margin: 0;
}
.account-sidebar .buttons p.cemu-warning {
margin: 4px 0 0;
font-size: 0.7rem;
color: var(--text-shade-1);
}
.account-sidebar .buttons #account-delete {
background-color: #f44336;
}
.modal p.noundo {
font-weight: bold;
color: var(--text-shade-3)
}
/* Settings */
.settings-wrapper {
display: grid;
grid-column-start: 2;
grid-template-columns: 1fr 1fr;
column-gap: 20px;
}
.settings-wrapper a {
color: var(--accent-shade-1);
text-decoration: none;
font-weight: bold;
}
.settings-wrapper a:hover {
text-decoration: underline;
}
.settings-wrapper h2.section-header {
margin-top: 40px;
grid-column: 1 / 3;
color: var(--text-shade-3);
}
.setting-card {
display: grid;
grid-template-rows: 35px repeat(2, auto);
row-gap: 24px;
position: relative;
border-radius: 10px;
background: var(--bg-shade-2);
padding: 48px 60px;
}
.setting-card * {
margin: 0;
}
.setting-card .edit {
color: var(--text-shade-1);
background: var(--bg-shade-3);
border-radius: 100%;
position: absolute;
top: 42px;
right: 48px;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.setting-card .edit:hover {
background: var(--bg-shade-3);
color: var(--text-shade-3);
}
.setting-card .edit svg {
pointer-events: none;
}
.setting-card .header {
color: var(--text-shade-3);
}
.setting-card .setting-list {
display: grid;
grid-template-columns: repeat(2, auto);
gap: 24px;
list-style: none;
padding: 0;
}
.setting-card .setting-list p.label {
color: var(--text-shade-3);
margin-bottom: 4px;
}
fieldset {
position: relative;
height: min-content;
padding: 0;
border: none;
}
.setting-card .server-selection {
display: flex;
border-radius: 5px;
overflow: hidden;
background: var(--bg-shade-3);
}
.setting-card .server-selection input {
display: none;
}
.server-selection input + label {
display: flex;
flex-flow: column;
align-items: center;
flex: 50%;
color: var(--text-shade-1);
padding: 40px;
justify-content: space-between;
cursor: pointer;
}
.server-selection input + label h2 {
margin-top: 12px;
color: var(--text-shade-1);
}
.server-selection input:checked + label,
.server-selection input:checked + label h2 {
background: var(--accent-shade-0);
color: var(--text-shade-3);
}
.setting-card #link-discord-account {
width: 100%;
padding: 12px 48px;
cursor: pointer;
background: var(--bg-shade-3);
}
.setting-card .discord-profile {
display: inline-flex;
align-items: center;
gap: 6px;
}
.setting-card button {
width: 100%;
height: fit-content;
padding: 12px 48px;
align-self: flex-end;
cursor: pointer;
background: var(--bg-shade-3);
}
.setting-card.span-both-columns {
grid-column: 1 / span 2;
}
@keyframes banner-notice {
0% {
top: -150px;
}
20% {
top: 35px;
}
80% {
top: 35px;
}
100% {
top: -150px;
}
}
.banner-notice {
display: flex;
justify-content: center;
position: absolute;
top: -150px;
left: 0;
width: 100vw;
animation: banner-notice 5s;
z-index: 100;
color: var(--text-shade-3)
}
.banner-notice div {
padding: 4px 36px;
border-radius: 5px;
z-index: 3;
}
.banner-notice.success div {
background: var(--green-shade-0);
}
.banner-notice.error div {
background: var(--red-shade-1);
}
footer {
margin-top: 80px;
}
@media screen and (max-width: 1300px) {
.account-wrapper {
margin: 20px 0;
}
.settings-wrapper {
grid-column-start: 1;
}
.account-sidebar {
margin: 0;
}
.account-sidebar .user .mii {
width: 128px;
height: 128px;
}
}
@media screen and (max-width: 1000px) {
.settings-wrapper {
display: block;
width: 100%;
}
.setting-card {
margin-bottom: 24px;
}
}
@media screen and (max-width: 550px) {
.setting-card {
padding: 24px;
width: calc(100vw - 48px);
margin-left: -5vw;
margin-right: -2.5vw;
border-radius: 0;
margin-bottom: 12px;
}
.setting-card .edit {
top: 20px;
right: 20px;
transform: scale(0.85);
}
.setting-card .server-selection {
flex-flow: column;
}
}
@media screen and (max-width: 350px) {
.setting-card .setting-list {
grid-template-columns: auto;
}
}
</style>

View File

@@ -1,13 +1,46 @@
<script setup lang="ts">
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger
} from 'reka-ui';
import type { ApiAccountCheckoutRequest } from '~~/shared/api-types';
const { t } = useI18n();
definePageMeta({
needsAuth: true
});
const { data: tierData } = await useApiFetch('/api/account/tiers');
const { data: profile } = await useApiFetch('/api/auth/me');
const sortedTiers = computed(() => [...tierData.value?.tiers ?? []].sort((a, b) => a.tierLevel - b.tierLevel));
const sortedTiers = computed(() =>
[...(tierData.value?.tiers ?? [])].sort((a, b) => a.tierLevel - b.tierLevel)
);
const selectedTier = ref(profile.value?.stripeTier);
const selectedTierPriceId = computed(() => selectedTier?.value?.priceId || '');
const modalIsOpen = ref(false);
const dialogContainer = ref(null);
const progress = await useFetch('/api/progress');
const donations = computed(() => progress.data.value?.donations);
const goalPercentage = computed(() => Math.floor((donations.value?.currentCents ?? 0) / (donations.value?.goalCents ?? 0) * 100));
const goalText = computed(() => {
return t('donation.progress', {
totd: Math.round((donations.value?.currentCents ?? 0) / 100).toString(),
goald: Math.round((donations.value?.goalCents ?? 0) / 100).toString(),
perc: goalPercentage.value.toString()
});
});
async function unsubscribe() {
try {
@@ -37,43 +70,568 @@ async function checkout(priceId: string) {
}
function hasSubscription(priceId: string) {
return profile.value?.stripeTier && priceId === profile.value.stripeTier.priceId;
return (
profile.value?.stripeTier && priceId === profile.value.stripeTier.priceId
);
}
useHead({
title: `Upgrade`
});
</script>
<template>
<div>
<h1>Tiers</h1>
<div
v-for="tier of sortedTiers"
:key="tier.priceId"
:style="{ border: '1px solid white'}"
<div class="account-form-wrapper">
<a
href="/account"
class="back-arrow"
>
<h2>{{ tier.name }}</h2>
<p>{{ tier.description }}</p>
<p v-if="tier.perks.beta">
Perk: Beta access
</p>
<p v-if="tier.perks.discordRead">
Perk: Read discord channels
</p>
<img
v-if="tier.thumbnailUrl"
:src="tier.thumbnailUrl"
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="feather feather-arrow-left"
>
<p>Cost: {{ tier.priceCents }} cents</p>
<button
v-if="hasSubscription(tier.priceId)"
@click="unsubscribe()"
<line
x1="19"
y1="12"
x2="5"
y2="12"
/>
<polyline points="12 19 5 12 12 5" />
</svg>
<span>{{ $t("upgrade.back") }}</span>
</a>
<a
class="logotype"
href="/"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="120"
height="39.876"
>
Unsubscribe
</button>
<button
<g
id="logo_type"
data-name="logo type"
transform="translate(-553 -467)"
>
<g
id="logo"
transform="translate(553 467)"
>
<rect
id="XMLID_158_"
width="39.876"
height="39.876"
fill="#9d6ff3"
opacity="0"
/>
<g
id="XMLID_6_"
transform="translate(8.222 1.418)"
>
<path
id="XMLID_15_"
d="M69.149,28.312c-1.051.553-.129,2.139.922,1.585a12.365,12.365,0,0,1,8.794-.571,10.829,10.829,0,0,1,6.342,4.166c.645,1,2.231.074,1.585-.922C83.308,27.169,74.7,25.436,69.149,28.312Z"
transform="translate(-64.246 -23.389)"
fill="#9d6ff3"
/>
<path
id="XMLID_14_"
d="M82.64,14.608A15.565,15.565,0,0,0,73.5,8.45a17.535,17.535,0,0,0-12.647.9c-1.051.553-.129,2.139.922,1.585,3.411-1.788,7.6-1.714,11.209-.719,3.1.848,6.268,2.544,8.038,5.309C81.681,16.543,83.267,15.622,82.64,14.608Z"
transform="translate(-57.476 -7.693)"
fill="#9d6ff3"
/>
<path
id="XMLID_9_"
d="M55.68,47.8a10.719,10.719,0,0,0-6.71,2.3H45.983A1.336,1.336,0,0,0,44.6,51.376V75.84a1.431,1.431,0,0,0,1.383,1.383h3.023a1.367,1.367,0,0,0,1.309-1.383V68.392A10.993,10.993,0,1,0,55.68,47.8Zm0,17.182a6.213,6.213,0,1,1,6.213-6.213A6.216,6.216,0,0,1,55.68,64.982Z"
transform="translate(-44.6 -40.406)"
fill="#9d6ff3"
/>
</g>
</g>
<text
id="Pretendo"
transform="translate(593 492)"
fill="#fff"
font-size="17"
font-family="Poppins-Bold, Poppins"
font-weight="700"
>
<tspan
x="0"
y="0"
>Pretendo</tspan>
</text>
</g>
</svg>
</a>
<h1 class="title dot">
{{ $t("upgrade.title") }}
</h1>
<p class="caption">
{{ $t("upgrade.description") }}
</p>
<div class="progress-bar-wrapper">
<div
class="progress-bar"
:style="{
'--progress-bar-width': goalPercentage + '%',
}"
>
<div
v-if="goalPercentage >= 100"
class="progress-bar-real"
/>
<div class="progress-bar-capped" />
</div>
<p
class="localeReplace"
v-html="goalText"
/>
</div>
<form method="post">
<template
v-for="tier of sortedTiers"
:key="tier.priceId"
>
<input
:id="tier.priceId"
v-model="selectedTierPriceId"
type="radio"
class="tier-radio"
:data-tier-name="tier.name"
:value="tier.priceId"
@click="selectedTier = tier"
>
<label
class="tier"
:for="tier.priceId"
>
<div class="tier-thumbnail">
<img
:src="tier.thumbnailUrl"
width="100%"
height="auto"
alt="Tier icon"
>
</div>
<div class="tier-text">
<p class="tier-name">{{ tier.name }}</p>
<div class="tier-perks">
<p v-if="tier.perks.beta">
<Icon
name="ph:flask"
size="20"
style="color: var(--green-shade-1)"
/>
Beta server access
</p>
<p v-if="tier.perks.discordRead">
<Icon
name="ph:chats"
size="20"
style="color: var(--accent-shade-2)"
/>
Read-only access to select dev channels on Discord
</p>
</div>
</div>
<p class="price">
<span>${{ tier.priceCents / 100 }}</span> /
{{ $t("upgrade.month") }}
</p>
</label>
</template>
<div
v-if="hasSubscription(selectedTier?.priceId || '')"
class="button-wrapper"
>
<AlertDialogRoot v-model:open="modalIsOpen">
<AlertDialogTrigger>
{{ $t("upgrade.unsub") }}
</AlertDialogTrigger>
<AlertDialogPortal :to="dialogContainer">
<AlertDialogOverlay />
<AlertDialogContent class="modal">
<AlertDialogTitle>{{ $t("upgrade.unsub") }}?</AlertDialogTitle>
<AlertDialogDescription class="modal-caption">
<span v-html="$t('upgrade.unsubPrompt').replace('tiername', profile?.stripeTier?.tierName)" />
</AlertDialogDescription>
<div class="modal-button-wrapper">
<AlertDialogCancel class="cancel">
{{ $t("modals.cancel") }}
</AlertDialogCancel>
<AlertDialogAction
class="alert"
@click="unsubscribe"
>
{{ $t("upgrade.unsubConfirm") }}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
<div
v-else-if="profile?.stripeTier"
class="button-wrapper"
>
<AlertDialogRoot v-model:open="modalIsOpen">
<AlertDialogTrigger>
{{ $t("upgrade.changeTier") }}
</AlertDialogTrigger>
<AlertDialogPortal :to="dialogContainer">
<AlertDialogOverlay />
<AlertDialogContent class="modal">
<AlertDialogTitle>{{ $t("upgrade.changeTier") }}?</AlertDialogTitle>
<AlertDialogDescription class="modal-caption">
<span v-html="$t('upgrade.changeTierPrompt').replace('oldtiername', profile?.stripeTier?.tierName).replace('newtiername', selectedTier?.name)" />
</AlertDialogDescription>
<div class="modal-button-wrapper">
<AlertDialogCancel class="cancel">
{{ $t("modals.cancel") }}
</AlertDialogCancel>
<AlertDialogAction
class="action"
@click="() => checkout(selectedTier?.priceId || '')"
>
{{ $t("modals.confirm") }}
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
<div
v-else-if="selectedTier?.priceId"
class="button-wrapper"
>
<button
@click.prevent="checkout(selectedTier.priceId)"
>
Subscribe to {{ selectedTier.name }}
</button>
</div>
<div
v-else
@click="checkout(tier.priceId)"
class="button-wrapper"
>
Checkout
</button>
<button
disabled
>
{{ $t('upgrade.tierSelectPrompt') }}
</button>
</div>
</form>
<div
id="unsub"
:class="{ 'modal-wrapper': true, hidden: !modalIsOpen }"
>
<div ref="dialogContainer" />
</div>
</div>
</template>
<style scoped>
.back-arrow {
position: relative;
display: flex;
justify-content: center;
top: 48px;
left: 0;
width: fit-content;
padding: 6px 10px;
background: var(--bg-shade-3);
border-radius: 24px;
transition: filter 150ms;
text-decoration: none;
color: var(--text-shade-3);
z-index: 5;
}
.back-arrow:hover {
filter: brightness(1.5);
}
.back-arrow svg {
width: 24px;
height: 24px;
}
.back-arrow span {
margin: 0 4px;
}
.account-form-wrapper {
display: flex;
flex-flow: column;
width: min(1200px, 100%);
color: var(--text-shade-1);
margin: 0 auto 48px;
text-align: center;
z-index: 1;
}
.account-form-wrapper .logotype {
margin: 36px auto 0;
width: fit-content;
}
h1.title {
color: var(--text-shade-3);
}
p.caption {
width: min(100%, 500px);
margin: 0 auto 36px;
}
.account-form-wrapper .progress-bar-wrapper {
justify-content: center;
width: min(100%, 500px);
margin: 0 auto 72px;
padding: 24px;
border-radius: 6px;
background: var(--bg-shade-2);
box-sizing: border-box;
}
.account-form-wrapper .progress-bar-wrapper p {
text-align: left;
margin-bottom: 0;
}
.account-form-wrapper .progress-bar-wrapper p span {
color: var(--text-shade-3);
font-weight: 600;
}
.account-form-wrapper .progress-bar {
height: 8px;
border-radius: 4px;
margin-top: 0;
}
form {
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.3rem;
}
form .tier-radio {
display: none;
}
form .tier-radio:checked + label::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-shadow: inset 0 0 0 4px var(--accent-shade-1);
border-radius: 10px;
}
form .tier-radio:checked + label::after {
content: url(/assets/images/check.svg);
display: flex;
justify-content: center;
background: var(--accent-shade-1);
width: 24px;
height: 24px;
border-radius: 100%;
position: absolute;
top: -16px;
right: -16px;
padding: 6px;
}
label.tier {
display: flex;
flex-flow: column;
position: relative;
border-radius: 10px;
align-items: center;
padding-top: calc(50px + 1rem);
background: var(--bg-shade-3);
cursor: pointer;
transition: all 150ms;
margin-top: 50px;
text-align: center;
}
label.tier p {
margin: 0;
margin-bottom: 0.5rem;
}
label.tier .tier-thumbnail {
height: 100px;
width: 100px;
display: flex;
align-items: center;
overflow: hidden;
border-radius: 8px;
position: absolute;
top: -50px;
z-index: 2;
background: var(--bg-shade-4);
padding: 8px;
box-sizing: border-box;
}
form .tier-radio:checked + label .tier-thumbnail::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
box-shadow: inset 0 0 0 4px var(--accent-shade-1);
border-radius: 8px;
}
label.tier .tier-text {
display: flex;
flex-flow: column;
margin-bottom: auto;
}
label.tier .tier-name {
color: var(--text-shade-3);
font-weight: bold;
font-size: 1.2rem;
}
label.tier .tier-perks {
text-align: left;
width: 70%;
margin: 24px auto 48px;
}
label.tier .tier-perks p span {
position: relative;
top: 4px;
}
label.tier .tier-perks div {
display: grid;
grid-template-columns: 16px auto;
gap: 8px;
}
label.tier p.price {
display: flex;
width: 100%;
justify-content: center;
align-items: center;
background: var(--bg-shade-4);
margin: 0;
padding: 1.5rem 1rem;
box-sizing: border-box;
border-radius: 0 0 10px 10px;
}
label.tier p.price span {
font-size: 2rem;
color: var(--text-shade-3);
font-weight: bold;
margin-right: 0.5ch;
}
form .button-wrapper {
grid-column: 2 / span 1;
position: relative;
margin-top: 24px;
}
button {
appearance: none;
-webkit-appearance: none;
display: block;
font-family: Poppins, Arial, Helvetica, sans-serif;
font-size: 1rem;
height: fit-content;
background: var(--accent-shade-0);
border: none;
border-radius: 4px;
padding: 12px;
color: var(--text-shade-3);
width: 100%;
transition: filter 300ms;
pointer-events: all;
cursor: pointer;
filter: none;
}
form button:disabled {
pointer-events: none;
filter: brightness(0.75) saturate(0.75) !important; /* not using opacity here 'cause in the mobile layout you would see the cards under it */
cursor: default;
}
.alertDialogRoot {
position: absolute;
background-color: #f00;
}
form button.unsubscribe {
position: relative;
background: none;
color: var(--text-shade-1);
margin-top: 12px;
padding: 0;
}
@media screen and (max-width: 900px) {
.account-form-wrapper {
width: min(500px, 100%);
margin-bottom: 172px;
}
form {
grid-template-columns: 1fr;
gap: 2.4rem;
}
form button {
position: relative;
width: 100%;
}
form .button-wrapper {
grid-column: 1 / span 1;
position: fixed;
bottom: 24px;
width: min(500px, 90%);
z-index: 5;
}
form .button-wrapper::before {
content: "";
position: absolute;
top: -24px;
left: -100vw;
width: 200vw;
height: 300%;
background: var(--bg-shade-0);
}
}
@media screen and (max-width: 380px) {
label.tier .tier-perks {
width: 80%;
}
.back-arrow {
padding: 6px;
}
.back-arrow span {
display: none;
}
}
</style>

View File

@@ -4,8 +4,6 @@ const { data: error } = await useAsyncData(`${code}`, () => {
return queryCollection('errorcodes').where('stem', '=', code).first();
});
console.log(error.value);
if (!error.value) {
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' });
}

View File

@@ -7,6 +7,16 @@ const { data: termContent } = await useAsyncData(`terms-${slug}`, () => {
if (!termContent.value) {
throw createError({ statusCode: 404, statusMessage: 'Page Not Found' });
}
useHead({
title: `${termContent.value.title} | Docs`,
meta: [
{ property: 'description', content: termContent.value.description },
{ property: 'og:description', content: termContent.value.description },
{ property: 'og:image:alt', content: '' },
{ name: 'twitter:description', content: termContent.value.description }
]
});
</script>
<template>

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="#fff" d="M5 19h1.4l8.625-8.625l-1.4-1.4L5 17.6ZM19.3 8.925l-4.25-4.2l1.4-1.4q.575-.575 1.413-.575q.837 0 1.412.575l1.4 1.4q.575.575.6 1.388q.025.812-.55 1.387ZM4 21q-.425 0-.712-.288Q3 20.425 3 20v-2.825q0-.2.075-.387q.075-.188.225-.338l10.3-10.3l4.25 4.25l-10.3 10.3q-.15.15-.337.225q-.188.075-.388.075ZM14.325 9.675l-.7-.7l1.4 1.4Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="#fff" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM51.31,160,136,75.31,152.69,92,68,176.68ZM48,179.31,76.69,208H48Zm48,25.38L79.31,188,164,103.31,180.69,120Zm96-96L147.31,64l24-24L216,84.68Z"></path></svg>

Before

Width:  |  Height:  |  Size: 438 B

After

Width:  |  Height:  |  Size: 433 B