Skip to content

Commit 35ad6f3

Browse files
authored
create account on purchase (#1966)
## Description: When purchasing an item, user will be logged in as their email automatically. * Users can be logged in either via discord or email (the top right button has an email or discord icon depending on which is logged in * Created AccountModal to show current login and has option to log in via Discord or send recovery email * Created TokenLoginModal which is triggered during account recovery or after purchase * Update DiscordUserSchema to * Removed choco pattern key listeners, they were causing NPEs when empty input was provided on forms <img width="408" height="479" alt="Screenshot 2025-08-29 at 5 35 31 PM" src="https://github.com/user-attachments/assets/a2be5556-b534-4279-931b-799d8ece122c" /> support email or discord identity <img width="801" height="351" alt="Screenshot 2025-08-29 at 5 38 59 PM" src="https://github.com/user-attachments/assets/9d18ef8f-a6f8-4c22-b583-c31d9b176467" /> <img width="97" height="83" alt="Screenshot 2025-08-29 at 5 39 51 PM" src="https://github.com/user-attachments/assets/994d7ade-fa02-4adb-a6f8-e929af4089b2" /> <img width="102" height="83" alt="Screenshot 2025-08-29 at 5 40 03 PM" src="https://github.com/user-attachments/assets/f829dd49-996b-479d-9b75-d81092e31da4" /> <img width="59" height="43" alt="Screenshot 2025-08-29 at 5 40 19 PM" src="https://github.com/user-attachments/assets/aacf39e7-2528-463b-95cb-a58bc8c2194b" /> ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
1 parent 3574210 commit 35ad6f3

File tree

15 files changed

+551
-114
lines changed

15 files changed

+551
-114
lines changed

resources/images/DiscordIcon.svg

Lines changed: 0 additions & 1 deletion
This file was deleted.

resources/images/DiscordLogo.svg

Lines changed: 3 additions & 0 deletions
Loading

resources/images/EmailIcon.svg

Lines changed: 5 additions & 0 deletions
Loading

resources/images/LoggedOutIcon.svg

Lines changed: 1 addition & 0 deletions
Loading

resources/lang/en.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,17 @@
134134
"enables_title": "Enable Settings",
135135
"start": "Start Game"
136136
},
137+
"token_login_modal": {
138+
"title": "Logging in...",
139+
"logging_in": "Logging in...",
140+
"success": "Successfully logged in as {email}!"
141+
},
142+
"account_modal": {
143+
"title": "Account",
144+
"logged_in_as": "Logged in as {email}",
145+
"logged_in_with_discord": "Logged in with Discord",
146+
"recovery_email_sent": "Recovery email sent to {email}"
147+
},
137148
"map": {
138149
"map": "Map",
139150
"world": "World",

src/client/AccountModal.ts

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
import { html, LitElement, TemplateResult } from "lit";
2+
import { customElement, query, state } from "lit/decorators.js";
3+
import { UserMeResponse } from "../core/ApiSchemas";
4+
import "./components/Difficulties";
5+
import "./components/PatternButton";
6+
import { discordLogin, getApiBase, getUserMe, logOut } from "./jwt";
7+
import { translateText } from "./Utils";
8+
9+
@customElement("account-modal")
10+
export class AccountModal extends LitElement {
11+
@query("o-modal") private modalEl!: HTMLElement & {
12+
open: () => void;
13+
close: () => void;
14+
};
15+
16+
@state() private email: string = "";
17+
18+
private loggedInEmail: string | null = null;
19+
private loggedInDiscord: string | null = null;
20+
21+
constructor() {
22+
super();
23+
}
24+
25+
createRenderRoot() {
26+
return this;
27+
}
28+
29+
render() {
30+
return html`
31+
<o-modal
32+
id="account-modal"
33+
title="${translateText("account_modal.title") || "Account"}"
34+
>
35+
${this.renderInner()}
36+
</o-modal>
37+
`;
38+
}
39+
40+
private renderInner() {
41+
if (this.loggedInDiscord) {
42+
return this.renderLoggedInDiscord();
43+
} else if (this.loggedInEmail) {
44+
return this.renderLoggedInEmail();
45+
} else {
46+
return this.renderLoginOptions();
47+
}
48+
}
49+
50+
private renderLoggedInDiscord() {
51+
return html`
52+
<div class="p-6">
53+
<div class="mb-4">
54+
<p class="text-white text-center mb-4">
55+
Logged in with Discord as ${this.loggedInDiscord}
56+
</p>
57+
</div>
58+
${this.logoutButton()}
59+
</div>
60+
`;
61+
}
62+
63+
private renderLoggedInEmail(): TemplateResult {
64+
return html`
65+
<div class="p-6">
66+
<div class="mb-4">
67+
<p class="text-white text-center mb-4">
68+
Logged in as ${this.loggedInEmail}
69+
</p>
70+
</div>
71+
${this.logoutButton()}
72+
</div>
73+
`;
74+
}
75+
76+
private logoutButton(): TemplateResult {
77+
return html`
78+
<button
79+
@click="${this.handleLogout}"
80+
class="px-6 py-3 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-200"
81+
>
82+
Log Out
83+
</button>
84+
`;
85+
}
86+
87+
private renderLoginOptions() {
88+
return html`
89+
<div class="p-6">
90+
<div class="mb-6">
91+
<h3 class="text-lg font-medium text-white mb-4 text-center">
92+
Choose your login method
93+
</h3>
94+
95+
<!-- Discord Login Button -->
96+
<div class="mb-6">
97+
<button
98+
@click="${this.handleDiscordLogin}"
99+
class="w-full px-6 py-3 text-sm font-medium text-white bg-[#5865F2] border border-transparent rounded-md hover:bg-[#4752C4] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#5865F2] transition-colors duration-200 flex items-center justify-center space-x-2"
100+
>
101+
<img
102+
src="/images/DiscordLogo.svg"
103+
alt="Discord"
104+
class="w-5 h-5"
105+
/>
106+
<span
107+
>${translateText("main.login_discord") ||
108+
"Login with Discord"}</span
109+
>
110+
</button>
111+
</div>
112+
113+
<!-- Divider -->
114+
<div class="relative mb-6">
115+
<div class="absolute inset-0 flex items-center">
116+
<div class="w-full border-t border-gray-300"></div>
117+
</div>
118+
<div class="relative flex justify-center text-sm">
119+
<span class="px-2 bg-gray-800 text-gray-300">or</span>
120+
</div>
121+
</div>
122+
123+
<!-- Email Recovery -->
124+
<div class="mb-4">
125+
<label
126+
for="email"
127+
class="block text-sm font-medium text-white mb-2"
128+
>
129+
Recover account by email
130+
</label>
131+
<input
132+
type="email"
133+
id="email"
134+
name="email"
135+
.value="${this.email}"
136+
@input="${this.handleEmailInput}"
137+
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-black"
138+
placeholder="Enter your email address"
139+
required
140+
/>
141+
</div>
142+
</div>
143+
144+
<div class="flex justify-end space-x-3">
145+
<button
146+
@click="${this.close}"
147+
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
148+
>
149+
Cancel
150+
</button>
151+
<button
152+
@click="${this.handleSubmit}"
153+
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
154+
>
155+
Submit
156+
</button>
157+
</div>
158+
</div>
159+
`;
160+
}
161+
162+
private handleEmailInput(e: Event) {
163+
const target = e.target as HTMLInputElement;
164+
this.email = target.value;
165+
}
166+
167+
private async handleSubmit() {
168+
if (!this.email) {
169+
alert("Please enter an email address");
170+
return;
171+
}
172+
173+
try {
174+
const apiBase = getApiBase();
175+
const response = await fetch(`${apiBase}/magic-link`, {
176+
method: "POST",
177+
headers: {
178+
"Content-Type": "application/json",
179+
},
180+
body: JSON.stringify({
181+
redirectDomain: window.location.origin,
182+
email: this.email,
183+
}),
184+
});
185+
186+
if (response.ok) {
187+
alert(
188+
translateText("account_modal.recovery_email_sent", {
189+
email: this.email,
190+
}),
191+
);
192+
this.close();
193+
} else {
194+
console.error(
195+
"Failed to send recovery email:",
196+
response.status,
197+
response.statusText,
198+
);
199+
alert("Failed to send recovery email. Please try again.");
200+
}
201+
} catch (error) {
202+
console.error("Error sending recovery email:", error);
203+
alert("Error sending recovery email. Please try again.");
204+
}
205+
}
206+
207+
private handleDiscordLogin() {
208+
discordLogin();
209+
}
210+
211+
public async open() {
212+
const userMe = await getUserMe();
213+
if (userMe) {
214+
this.loggedInEmail = userMe.user.email ?? null;
215+
this.loggedInDiscord = userMe.user.discord?.global_name ?? null;
216+
}
217+
this.modalEl?.open();
218+
this.requestUpdate();
219+
}
220+
221+
public close() {
222+
this.modalEl?.close();
223+
}
224+
225+
private async handleLogout() {
226+
await logOut();
227+
this.close();
228+
// Refresh the page after logout to update the UI state
229+
window.location.reload();
230+
}
231+
}
232+
233+
@customElement("account-button")
234+
export class AccountButton extends LitElement {
235+
@state() private loggedInEmail: string | null = null;
236+
@state() private loggedInDiscord: string | null = null;
237+
238+
@query("account-modal") private recoveryModal: AccountModal;
239+
240+
constructor() {
241+
super();
242+
243+
document.addEventListener("userMeResponse", (event: Event) => {
244+
const customEvent = event as CustomEvent;
245+
246+
if (customEvent.detail) {
247+
const userMeResponse = customEvent.detail as UserMeResponse;
248+
if (userMeResponse.user.email) {
249+
this.loggedInEmail = userMeResponse.user.email;
250+
this.requestUpdate();
251+
} else if (userMeResponse.user.discord) {
252+
this.loggedInDiscord = userMeResponse.user.discord.id;
253+
this.requestUpdate();
254+
}
255+
} else {
256+
// Clear the logged in states when user logs out
257+
this.loggedInEmail = null;
258+
this.loggedInDiscord = null;
259+
this.requestUpdate();
260+
}
261+
});
262+
}
263+
264+
createRenderRoot() {
265+
return this;
266+
}
267+
268+
render() {
269+
let buttonTitle = "";
270+
if (this.loggedInEmail) {
271+
buttonTitle = translateText("account_modal.logged_in_as", {
272+
email: this.loggedInEmail,
273+
});
274+
} else if (this.loggedInDiscord) {
275+
buttonTitle = translateText("account_modal.logged_in_with_discord");
276+
}
277+
278+
return html`
279+
<div class="fixed top-4 right-4 z-[9999]">
280+
<button
281+
@click="${this.open}"
282+
class="w-12 h-12 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
283+
title="${buttonTitle}"
284+
>
285+
${this.renderIcon()}
286+
</button>
287+
</div>
288+
<account-modal></account-modal>
289+
`;
290+
}
291+
292+
private renderIcon() {
293+
if (this.loggedInDiscord) {
294+
return html`<img
295+
src="/images/DiscordLogo.svg"
296+
alt="Discord"
297+
class="w-6 h-6"
298+
/>`;
299+
} else if (this.loggedInEmail) {
300+
return html`<img
301+
src="/images/EmailIcon.svg"
302+
alt="Email"
303+
class="w-6 h-6"
304+
/>`;
305+
}
306+
return html`<img
307+
src="/images/LoggedOutIcon.svg"
308+
alt="Logged Out"
309+
class="w-6 h-6"
310+
/>`;
311+
}
312+
313+
private open() {
314+
this.recoveryModal?.open();
315+
}
316+
}

src/client/Cosmetics.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { UserMeResponse } from "../core/ApiSchemas";
22
import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas";
33
import { getApiBase, getAuthHeader } from "./jwt";
4+
import { getPersistentID } from "./Main";
45

56
export async function fetchPatterns(
67
userMe: UserMeResponse | null,
@@ -44,11 +45,11 @@ export async function handlePurchase(pattern: Pattern) {
4445
headers: {
4546
"Content-Type": "application/json",
4647
authorization: getAuthHeader(),
48+
"X-Persistent-Id": getPersistentID(),
4749
},
4850
body: JSON.stringify({
4951
priceId: pattern.product.priceId,
50-
successUrl: `${window.location.origin}#purchase-completed=true&pattern=${pattern.name}`,
51-
cancelUrl: `${window.location.origin}#purchase-completed=false`,
52+
hostname: window.location.origin,
5253
}),
5354
},
5455
);

src/client/DarkModeButton.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export class DarkModeButton extends LitElement {
3535
return html`
3636
<button
3737
title="Toggle Dark Mode"
38-
class="absolute top-0 right-0 md:top-[10px] md:right-[10px] border-none bg-none cursor-pointer text-2xl"
38+
class="absolute top-0 left-0 md:top-[10px] md:left-[10px] border-none bg-none cursor-pointer text-2xl"
3939
@click=${() => this.toggleDarkMode()}
4040
>
4141
${this.darkMode ? "☀️" : "🌙"}

0 commit comments

Comments
 (0)