Add online store
This commit is contained in:
parent
ea8c16747d
commit
72cb6ecb49
11 changed files with 797 additions and 7 deletions
12
bun.lock
12
bun.lock
|
@ -14,12 +14,14 @@
|
|||
"astro-icon": "^1.1.4",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.2",
|
||||
"pocketbase": "^0.25.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"tailwindcss": "^3.4.16",
|
||||
},
|
||||
"devDependencies": {
|
||||
"daisyui": "^4.12.23",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"tailwindcss-animated": "^1.1.2",
|
||||
|
@ -502,6 +504,8 @@
|
|||
|
||||
"css-selector-parser": ["css-selector-parser@3.0.5", "", {}, "sha512-3itoDFbKUNx1eKmVpYMFyqKX04Ww9osZ+dLgrk6GEv6KMVeXUhUnp4I5X+evw+u3ZxVU6RFXSSRxlTeMh8bA+g=="],
|
||||
|
||||
"css-selector-tokenizer": ["css-selector-tokenizer@0.8.0", "", { "dependencies": { "cssesc": "^3.0.0", "fastparse": "^1.1.2" } }, "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg=="],
|
||||
|
||||
"css-tree": ["css-tree@2.3.1", "", { "dependencies": { "mdn-data": "2.0.30", "source-map-js": "^1.0.1" } }, "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw=="],
|
||||
|
||||
"css-what": ["css-what@6.1.0", "", {}, "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="],
|
||||
|
@ -512,6 +516,10 @@
|
|||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"culori": ["culori@3.3.0", "", {}, "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ=="],
|
||||
|
||||
"daisyui": ["daisyui@4.12.23", "", { "dependencies": { "css-selector-tokenizer": "^0.8", "culori": "^3", "picocolors": "^1", "postcss-js": "^4" } }, "sha512-EM38duvxutJ5PD65lO/AFMpcw+9qEy6XAZrTpzp7WyaPeO/l+F/Qiq0ECHHmFNcFXh5aVoALY4MGrrxtCiaQCQ=="],
|
||||
|
||||
"debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="],
|
||||
|
@ -616,6 +624,8 @@
|
|||
|
||||
"fast-glob": ["fast-glob@3.3.2", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow=="],
|
||||
|
||||
"fastparse": ["fastparse@1.1.2", "", {}, "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ=="],
|
||||
|
||||
"fastq": ["fastq@1.17.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w=="],
|
||||
|
||||
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
|
||||
|
@ -1034,6 +1044,8 @@
|
|||
|
||||
"pkg-types": ["pkg-types@1.2.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.2", "pathe": "^1.1.2" } }, "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw=="],
|
||||
|
||||
"pocketbase": ["pocketbase@0.25.1", "", {}, "sha512-2IH0KLI/qMNR/E17C7BGWX2FxW7Tead+igLHOWZ45P56v/NyVT18Jnmddeft+3qWWGL1Hog2F8bc4orWV/+Fcg=="],
|
||||
|
||||
"postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="],
|
||||
|
||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
|
|
@ -21,12 +21,14 @@
|
|||
"astro-icon": "^1.1.4",
|
||||
"motion": "^11.15.0",
|
||||
"next": "^15.1.2",
|
||||
"pocketbase": "^0.25.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.4.0",
|
||||
"tailwindcss": "^3.4.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"daisyui": "^4.12.23",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"tailwindcss-animated": "^1.1.2",
|
||||
|
|
85
src/components/auth/RedirectHandler.ts
Normal file
85
src/components/auth/RedirectHandler.ts
Normal file
|
@ -0,0 +1,85 @@
|
|||
import PocketBase from "pocketbase";
|
||||
|
||||
export class RedirectHandler {
|
||||
private pb: PocketBase;
|
||||
private contentEl: HTMLElement;
|
||||
private params: URLSearchParams;
|
||||
private provider: any;
|
||||
|
||||
constructor() {
|
||||
this.pb = new PocketBase("https://pocketbase.ieeeucsd.org");
|
||||
this.contentEl = this.getContentElement();
|
||||
this.params = new URLSearchParams(window.location.search);
|
||||
this.provider = this.getStoredProvider();
|
||||
this.handleRedirect();
|
||||
}
|
||||
|
||||
private getContentElement(): HTMLElement {
|
||||
const contentEl = document.getElementById("content");
|
||||
if (!contentEl) {
|
||||
throw new Error("Content element not found");
|
||||
}
|
||||
return contentEl;
|
||||
}
|
||||
|
||||
private getStoredProvider() {
|
||||
return JSON.parse(localStorage.getItem("provider") || "{}");
|
||||
}
|
||||
|
||||
private showError(message: string) {
|
||||
this.contentEl.innerHTML = `<p class='text-red-500'>${message}</p>`;
|
||||
}
|
||||
|
||||
private async handleRedirect() {
|
||||
const code = this.params.get("code");
|
||||
const state = this.params.get("state");
|
||||
|
||||
if (!code) {
|
||||
this.showError("No authorization code found in URL.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== this.provider.state) {
|
||||
this.showError("Invalid state parameter.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const authData = await this.pb.collection("users").authWithOAuth2Code(
|
||||
"oidc",
|
||||
code,
|
||||
this.provider.codeVerifier,
|
||||
window.location.origin + "/oauth2-redirect",
|
||||
{ emailVisibility: false }
|
||||
);
|
||||
|
||||
console.log("Auth successful:", authData);
|
||||
this.contentEl.innerHTML = `
|
||||
<p class="text-3xl font-bold text-green-500 mb-4">Authentication Successful!</p>
|
||||
<p class="text-2xl font-medium">Redirecting to store...</p>
|
||||
<div class="mt-4">
|
||||
<div class="loading loading-spinner loading-lg"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
// Update last login before redirecting
|
||||
await this.pb.collection("users").update(authData.record.id, {
|
||||
last_login: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Clean up and redirect
|
||||
localStorage.removeItem("provider");
|
||||
window.location.href = "/online-store";
|
||||
} catch (err) {
|
||||
console.error("Failed to update last login:", err);
|
||||
// Still redirect even if last_login update fails
|
||||
localStorage.removeItem("provider");
|
||||
window.location.href = "/online-store";
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Auth error:", err);
|
||||
this.showError(`Failed to complete authentication: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
382
src/components/auth/StoreAuth.ts
Normal file
382
src/components/auth/StoreAuth.ts
Normal file
|
@ -0,0 +1,382 @@
|
|||
import PocketBase from "pocketbase";
|
||||
|
||||
interface AuthElements {
|
||||
loginButton: HTMLButtonElement;
|
||||
logoutButton: HTMLButtonElement;
|
||||
userInfo: HTMLDivElement;
|
||||
userName: HTMLParagraphElement;
|
||||
userEmail: HTMLParagraphElement;
|
||||
memberStatus: HTMLDivElement;
|
||||
lastLogin: HTMLParagraphElement;
|
||||
storeContent: HTMLDivElement;
|
||||
resumeUpload: HTMLInputElement;
|
||||
resumeName: HTMLParagraphElement;
|
||||
resumeDownload: HTMLAnchorElement;
|
||||
deleteResume: HTMLButtonElement;
|
||||
uploadStatus: HTMLParagraphElement;
|
||||
resumeActions: HTMLDivElement;
|
||||
memberIdInput: HTMLInputElement;
|
||||
saveMemberId: HTMLButtonElement;
|
||||
memberIdStatus: HTMLParagraphElement;
|
||||
}
|
||||
|
||||
export class StoreAuth {
|
||||
private pb: PocketBase;
|
||||
private elements: AuthElements & { loadingSkeleton: HTMLDivElement };
|
||||
private isEditingMemberId: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this.pb = new PocketBase("https://pocketbase.ieeeucsd.org");
|
||||
this.elements = this.getElements();
|
||||
this.init();
|
||||
}
|
||||
|
||||
private getElements(): AuthElements & { loadingSkeleton: HTMLDivElement } {
|
||||
// Fun typescript fixes
|
||||
const loginButton = document.getElementById("loginButton") as HTMLButtonElement;
|
||||
const logoutButton = document.getElementById("logoutButton") as HTMLButtonElement;
|
||||
const userInfo = document.getElementById("userInfo") as HTMLDivElement;
|
||||
const loadingSkeleton = document.getElementById("loadingSkeleton") as HTMLDivElement;
|
||||
|
||||
// Add CSS for loading state transitions
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.loading-state {
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
.content-ready {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const userName = document.getElementById("userName") as HTMLParagraphElement;
|
||||
const userEmail = document.getElementById("userEmail") as HTMLParagraphElement;
|
||||
const memberStatus = document.getElementById("memberStatus") as HTMLDivElement;
|
||||
const lastLogin = document.getElementById("lastLogin") as HTMLParagraphElement;
|
||||
const storeContent = document.getElementById("storeContent") as HTMLDivElement;
|
||||
const resumeUpload = document.getElementById("resumeUpload") as HTMLInputElement;
|
||||
const resumeName = document.getElementById("resumeName") as HTMLParagraphElement;
|
||||
const resumeDownload = document.getElementById("resumeDownload") as HTMLAnchorElement;
|
||||
const deleteResume = document.getElementById("deleteResume") as HTMLButtonElement;
|
||||
const uploadStatus = document.getElementById("uploadStatus") as HTMLParagraphElement;
|
||||
const resumeActions = document.getElementById("resumeActions") as HTMLDivElement;
|
||||
const memberIdInput = document.getElementById("memberIdInput") as HTMLInputElement;
|
||||
const saveMemberId = document.getElementById("saveMemberId") as HTMLButtonElement;
|
||||
const memberIdStatus = document.getElementById("memberIdStatus") as HTMLParagraphElement;
|
||||
|
||||
if (!loginButton || !logoutButton || !userInfo || !storeContent || !userName || !userEmail ||
|
||||
!memberStatus || !lastLogin || !resumeUpload || !resumeName || !loadingSkeleton ||
|
||||
!resumeDownload || !deleteResume || !uploadStatus || !resumeActions ||
|
||||
!memberIdInput || !saveMemberId || !memberIdStatus) {
|
||||
throw new Error("Required DOM elements not found");
|
||||
}
|
||||
|
||||
return {
|
||||
loginButton, logoutButton, userInfo, userName, userEmail, memberStatus,
|
||||
lastLogin, storeContent, resumeUpload, resumeName, loadingSkeleton,
|
||||
resumeDownload, deleteResume, uploadStatus, resumeActions,
|
||||
memberIdInput, saveMemberId, memberIdStatus
|
||||
};
|
||||
}
|
||||
|
||||
private updateMemberIdState() {
|
||||
const { memberIdInput, saveMemberId } = this.elements;
|
||||
const user = this.pb.authStore.model;
|
||||
|
||||
if (user?.member_id && !this.isEditingMemberId) {
|
||||
// Has member ID and not editing - show update button and disable input
|
||||
memberIdInput.disabled = true;
|
||||
memberIdInput.value = user.member_id;
|
||||
saveMemberId.textContent = "Update";
|
||||
saveMemberId.classList.remove("btn-primary");
|
||||
saveMemberId.classList.add("btn-ghost");
|
||||
} else {
|
||||
// No member ID or editing - show save button and enable input
|
||||
memberIdInput.disabled = false;
|
||||
saveMemberId.textContent = "Save";
|
||||
saveMemberId.classList.remove("btn-ghost");
|
||||
saveMemberId.classList.add("btn-primary");
|
||||
}
|
||||
}
|
||||
|
||||
private async updateUI() {
|
||||
const { loginButton, logoutButton, userInfo, userName, userEmail, memberStatus,
|
||||
lastLogin, storeContent, resumeName, resumeDownload, resumeActions,
|
||||
memberIdInput, saveMemberId, resumeUpload, loadingSkeleton } = this.elements;
|
||||
|
||||
// Hide buttons initially
|
||||
loginButton.style.display = 'none';
|
||||
logoutButton.style.display = 'none';
|
||||
|
||||
if (this.pb.authStore.isValid && this.pb.authStore.model) {
|
||||
// Update all the user information first
|
||||
const user = this.pb.authStore.model;
|
||||
userName.textContent = user.name || "Name not provided";
|
||||
userEmail.textContent = user.email || "Email not available";
|
||||
|
||||
// Update member status
|
||||
if (user.verified) {
|
||||
// Check and update member_type if not set
|
||||
if (!user.member_type) {
|
||||
try {
|
||||
const isIeeeOfficer = user.email?.toLowerCase().endsWith('@ieeeucsd.org') || false;
|
||||
const newMemberType = isIeeeOfficer ? "IEEE Officer" : "Regular Member";
|
||||
|
||||
await this.pb.collection("users").update(user.id, {
|
||||
member_type: newMemberType
|
||||
});
|
||||
|
||||
user.member_type = newMemberType;
|
||||
} catch (err) {
|
||||
console.error("Failed to update member type:", err);
|
||||
}
|
||||
}
|
||||
|
||||
memberStatus.textContent = user.member_type || "Regular Member";
|
||||
memberStatus.classList.remove("badge-neutral", "badge-success", "badge-warning", "badge-info", "badge-error");
|
||||
|
||||
// Set color based on member type
|
||||
if (user.member_type === "IEEE Administrator") {
|
||||
memberStatus.classList.add("badge-warning"); // Red for administrators
|
||||
} else if (user.member_type === "IEEE Officer") {
|
||||
memberStatus.classList.add("badge-info"); // Blue for officers
|
||||
} else {
|
||||
memberStatus.classList.add("badge-neutral"); // Yellow for regular members
|
||||
}
|
||||
} else {
|
||||
memberStatus.textContent = "Not Verified";
|
||||
memberStatus.classList.remove("badge-info", "badge-warning", "badge-success", "badge-error");
|
||||
memberStatus.classList.add("badge-neutral");
|
||||
}
|
||||
|
||||
// Update member ID input and state
|
||||
memberIdInput.value = user.member_id || "";
|
||||
this.updateMemberIdState();
|
||||
|
||||
// Update last login
|
||||
const lastLoginDate = user.last_login ? new Date(user.last_login).toLocaleString() : "Never";
|
||||
lastLogin.textContent = lastLoginDate;
|
||||
|
||||
// Update resume section
|
||||
if (user.resume && (!Array.isArray(user.resume) || user.resume.length > 0)) {
|
||||
const resumeUrl = user.resume.toString();
|
||||
resumeName.textContent = this.getFileNameFromUrl(resumeUrl);
|
||||
resumeDownload.href = this.pb.files.getURL(user, resumeUrl);
|
||||
resumeActions.style.display = 'flex';
|
||||
} else {
|
||||
resumeName.textContent = "No resume uploaded";
|
||||
resumeDownload.href = "#";
|
||||
resumeActions.style.display = 'none';
|
||||
}
|
||||
|
||||
// After everything is updated, show the content
|
||||
loadingSkeleton.style.display = 'none';
|
||||
userInfo.classList.remove('hidden');
|
||||
// Use a small delay to ensure the transition works
|
||||
setTimeout(() => {
|
||||
userInfo.style.opacity = '1';
|
||||
}, 50);
|
||||
|
||||
logoutButton.style.display = 'block';
|
||||
} else {
|
||||
// Update for logged out state
|
||||
userName.textContent = "Not signed in";
|
||||
userEmail.textContent = "Not signed in";
|
||||
memberStatus.textContent = "Not verified";
|
||||
memberStatus.classList.remove("badge-info", "badge-warning", "badge-success", "badge-error");
|
||||
memberStatus.classList.add("badge-neutral");
|
||||
lastLogin.textContent = "Never";
|
||||
|
||||
// Reset member ID
|
||||
memberIdInput.value = "";
|
||||
memberIdInput.disabled = true;
|
||||
this.isEditingMemberId = false;
|
||||
this.updateMemberIdState();
|
||||
|
||||
// Reset resume section
|
||||
resumeName.textContent = "No resume uploaded";
|
||||
resumeDownload.href = "#";
|
||||
resumeActions.style.display = 'none';
|
||||
|
||||
// After everything is updated, show the content
|
||||
loadingSkeleton.style.display = 'none';
|
||||
userInfo.classList.remove('hidden');
|
||||
// Use a small delay to ensure the transition works
|
||||
setTimeout(() => {
|
||||
userInfo.style.opacity = '1';
|
||||
}, 50);
|
||||
|
||||
loginButton.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
private getFileNameFromUrl(url: string): string {
|
||||
const parts = url.split("/");
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
private async handleMemberIdButton() {
|
||||
const user = this.pb.authStore.model;
|
||||
|
||||
if (user?.member_id && !this.isEditingMemberId) {
|
||||
// If we have a member ID and we're not editing, switch to edit mode
|
||||
this.isEditingMemberId = true;
|
||||
this.updateMemberIdState();
|
||||
} else {
|
||||
// If we're editing or don't have a member ID, try to save
|
||||
await this.handleMemberIdSave();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleMemberIdSave() {
|
||||
const { memberIdInput, memberIdStatus } = this.elements;
|
||||
const memberId = memberIdInput.value.trim();
|
||||
|
||||
try {
|
||||
memberIdStatus.textContent = "Saving member ID...";
|
||||
|
||||
const user = this.pb.authStore.model;
|
||||
if (!user?.id) {
|
||||
throw new Error("User ID not found");
|
||||
}
|
||||
|
||||
await this.pb.collection("users").update(user.id, {
|
||||
member_id: memberId
|
||||
});
|
||||
|
||||
memberIdStatus.textContent = "Member ID saved successfully!";
|
||||
this.isEditingMemberId = false;
|
||||
this.updateUI();
|
||||
|
||||
// Clear the status message after a delay
|
||||
setTimeout(() => {
|
||||
memberIdStatus.textContent = "";
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
console.error("Member ID save error:", err);
|
||||
memberIdStatus.textContent = "Failed to save member ID. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResumeUpload(file: File) {
|
||||
const { uploadStatus } = this.elements;
|
||||
|
||||
try {
|
||||
uploadStatus.textContent = "Uploading resume...";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("resume", file);
|
||||
|
||||
const user = this.pb.authStore.model;
|
||||
if (!user?.id) {
|
||||
throw new Error("User ID not found");
|
||||
}
|
||||
|
||||
await this.pb.collection("users").update(user.id, formData);
|
||||
|
||||
uploadStatus.textContent = "Resume uploaded successfully!";
|
||||
this.updateUI();
|
||||
|
||||
// Clear the file input
|
||||
this.elements.resumeUpload.value = "";
|
||||
|
||||
// Clear the status message after a delay
|
||||
setTimeout(() => {
|
||||
uploadStatus.textContent = "";
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
console.error("Resume upload error:", err);
|
||||
uploadStatus.textContent = "Failed to upload resume. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
private async handleResumeDelete() {
|
||||
const { uploadStatus } = this.elements;
|
||||
|
||||
try {
|
||||
uploadStatus.textContent = "Deleting resume...";
|
||||
|
||||
const user = this.pb.authStore.model;
|
||||
if (!user?.id) {
|
||||
throw new Error("User ID not found");
|
||||
}
|
||||
|
||||
await this.pb.collection("users").update(user.id, {
|
||||
"resume": null
|
||||
});
|
||||
|
||||
uploadStatus.textContent = "Resume deleted successfully!";
|
||||
this.updateUI();
|
||||
|
||||
// Clear the status message after a delay
|
||||
setTimeout(() => {
|
||||
uploadStatus.textContent = "";
|
||||
}, 3000);
|
||||
} catch (err: any) {
|
||||
console.error("Resume deletion error:", err);
|
||||
uploadStatus.textContent = "Failed to delete resume. Please try again.";
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLogin() {
|
||||
console.log("Starting OAuth2 authentication...");
|
||||
try {
|
||||
const authMethods = await this.pb.collection("users").listAuthMethods();
|
||||
const oidcProvider = authMethods.oauth2?.providers?.find(
|
||||
(p: { name: string }) => p.name === "oidc"
|
||||
);
|
||||
|
||||
if (!oidcProvider) {
|
||||
throw new Error("OIDC provider not found");
|
||||
}
|
||||
|
||||
// Store provider info for the redirect page
|
||||
localStorage.setItem("provider", JSON.stringify(oidcProvider));
|
||||
|
||||
// Redirect to the authorization URL
|
||||
const redirectUrl = window.location.origin + "/oauth2-redirect";
|
||||
const authUrl = oidcProvider.authURL + encodeURIComponent(redirectUrl);
|
||||
window.location.href = authUrl;
|
||||
} catch (err: any) {
|
||||
console.error("Authentication error:", err);
|
||||
this.elements.userEmail.textContent = "Failed to start authentication";
|
||||
this.elements.userName.textContent = "Error";
|
||||
}
|
||||
}
|
||||
|
||||
private handleLogout() {
|
||||
this.pb.authStore.clear();
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Initial UI update with loading state
|
||||
this.updateUI().catch(console.error);
|
||||
|
||||
// Setup event listeners
|
||||
this.elements.loginButton.addEventListener("click", () => this.handleLogin());
|
||||
this.elements.logoutButton.addEventListener("click", () => this.handleLogout());
|
||||
|
||||
// Resume upload event listener
|
||||
this.elements.resumeUpload.addEventListener("change", (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
this.handleResumeUpload(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Resume delete event listener
|
||||
this.elements.deleteResume.addEventListener("click", () => this.handleResumeDelete());
|
||||
|
||||
// Member ID save event listener
|
||||
this.elements.saveMemberId.addEventListener("click", () => this.handleMemberIdButton());
|
||||
|
||||
// Listen for auth state changes
|
||||
this.pb.authStore.onChange(async (token) => {
|
||||
console.log("Auth state changed. IsValid:", this.pb.authStore.isValid);
|
||||
this.updateUI();
|
||||
});
|
||||
}
|
||||
}
|
193
src/components/auth/UserProfile.astro
Normal file
193
src/components/auth/UserProfile.astro
Normal file
|
@ -0,0 +1,193 @@
|
|||
<div>
|
||||
<!-- Loading Skeleton (shown by default) -->
|
||||
<div id="loadingSkeleton" class="card bg-base-200 shadow-xl">
|
||||
<div class="card-body p-0">
|
||||
<div class="px-6 pt-6">
|
||||
<!-- Title -->
|
||||
<h2 class="skeleton h-8 w-40 card-title"></h2>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="px-6 pb-6">
|
||||
<div class="space-y-3">
|
||||
<!-- Name -->
|
||||
<div class="space-y-1">
|
||||
<div class="skeleton h-3 w-16 opacity-70"></div>
|
||||
<div class="skeleton h-[1.75rem] w-48"></div>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="space-y-1">
|
||||
<div class="skeleton h-3 w-16 opacity-70"></div>
|
||||
<div class="skeleton h-[1.75rem] w-64"></div>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
|
||||
<!-- Member Status -->
|
||||
<div class="space-y-1">
|
||||
<div class="skeleton h-3 w-24 opacity-70"></div>
|
||||
<div class="skeleton h-[1.75rem] w-32"></div>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
|
||||
<!-- Member ID -->
|
||||
<div class="space-y-1">
|
||||
<div class="skeleton h-3 w-20 opacity-70"></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="skeleton h-8 flex-1"></div>
|
||||
<div class="skeleton h-8 w-16"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
|
||||
<!-- Last Login -->
|
||||
<div class="space-y-1">
|
||||
<div class="skeleton h-3 w-20 opacity-70"></div>
|
||||
<div class="skeleton h-[1.25rem] w-32"></div>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
|
||||
<!-- Resume -->
|
||||
<div class="space-y-1">
|
||||
<div class="skeleton h-3 w-16 opacity-70"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="skeleton h-[1.25rem] flex-1"></div>
|
||||
<div class="skeleton h-[1.25rem] w-24"></div>
|
||||
</div>
|
||||
<div class="skeleton h-8 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
|
||||
<!-- Auth Button -->
|
||||
<div class="pt-2">
|
||||
<div class="skeleton h-10 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actual Content (hidden by default) -->
|
||||
<div id="userInfo" class="card bg-base-200 shadow-xl opacity-0 hidden">
|
||||
<div class="card-body p-0">
|
||||
<div class="px-6 pt-6">
|
||||
<h2 class="card-title text-2xl">User Profile</h2>
|
||||
</div>
|
||||
<div class="px-6 pb-6">
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm opacity-70">Name</label>
|
||||
<p id="userName" class="h-[1.75rem] font-medium">
|
||||
Not signed in
|
||||
</p>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm opacity-70">Email</label>
|
||||
<p id="userEmail" class="h-[1.75rem] font-medium">
|
||||
Not signed in
|
||||
</p>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm opacity-70">Member Status</label>
|
||||
<div class="flex h-[1.75rem] items-center">
|
||||
<div id="memberStatus" class="badge badge-neutral">
|
||||
Not verified
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm opacity-70">Member ID</label>
|
||||
<div class="flex items-center gap-2 h-8">
|
||||
<input
|
||||
type="text"
|
||||
id="memberIdInput"
|
||||
placeholder="Enter your member ID"
|
||||
class="input input-bordered w-full h-8 min-h-[2rem] disabled:bg-base-300 disabled:border-2 disabled:border-opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
id="saveMemberId"
|
||||
class="btn btn-primary h-8 min-h-[2rem]"
|
||||
>Save</button
|
||||
>
|
||||
</div>
|
||||
<p id="memberIdStatus" class="text-xs mt-1 opacity-70">
|
||||
</p>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm opacity-70">Last Login</label>
|
||||
<p
|
||||
id="lastLogin"
|
||||
class="text-sm h-[1.25rem] opacity-80"
|
||||
>
|
||||
Never
|
||||
</p>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm opacity-70">Resume</label>
|
||||
<div id="resumeSection" class="space-y-2">
|
||||
<div class="flex items-center gap-2 h-[1.25rem]">
|
||||
<p
|
||||
id="resumeName"
|
||||
class="text-sm truncate flex-1"
|
||||
>
|
||||
No resume uploaded
|
||||
</p>
|
||||
<div id="resumeActions" class="flex gap-2">
|
||||
<a
|
||||
id="resumeDownload"
|
||||
href="#"
|
||||
target="_blank"
|
||||
class="btn btn-ghost btn-xs">View</a
|
||||
>
|
||||
<button
|
||||
id="deleteResume"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
>Delete</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div id="uploadSection">
|
||||
<input
|
||||
type="file"
|
||||
id="resumeUpload"
|
||||
accept=".pdf,.doc,.docx"
|
||||
class="file-input file-input-bordered file-input-sm w-full"
|
||||
/>
|
||||
<p
|
||||
id="uploadStatus"
|
||||
class="text-xs mt-1 opacity-70"
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divider my-0.5"></div>
|
||||
<div class="pt-2">
|
||||
<button id="loginButton" class="btn btn-primary w-full"
|
||||
>Sign in with IEEEUCSD SSO</button
|
||||
>
|
||||
<button id="logoutButton" class="btn btn-error w-full"
|
||||
>Sign Out</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
#userInfo {
|
||||
transition: opacity 0.3s ease-in-out;
|
||||
}
|
||||
</style>
|
37
src/components/store/StoreItem.astro
Normal file
37
src/components/store/StoreItem.astro
Normal file
|
@ -0,0 +1,37 @@
|
|||
---
|
||||
interface Props {
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
imageUrl = "https://placehold.co/400x300",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="card bg-base-200 shadow-xl">
|
||||
<figure class="px-6 pt-6">
|
||||
<div class="relative w-full">
|
||||
<div class="skeleton w-full aspect-[4/3] rounded-xl"></div>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={name}
|
||||
class="rounded-xl absolute inset-0 w-full h-full object-cover opacity-0 transition-opacity duration-300"
|
||||
onload="this.classList.remove('opacity-0')"
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{name}</h2>
|
||||
<p>{description}</p>
|
||||
<div class="card-actions justify-between items-center mt-2">
|
||||
<span class="text-xl font-semibold">${price.toFixed(2)}</span>
|
||||
<button class="btn btn-primary">Add to Cart</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -14,7 +14,9 @@ import InView from "../components/core/InView.astro";
|
|||
<title>Astro Basics</title>
|
||||
</head>
|
||||
<InView />
|
||||
<body class="text-white bg-ieee-black relative flex flex-col items-center min-h-screen justify-between overflow-x-clip">
|
||||
<body
|
||||
class="text-white bg-ieee-black relative flex flex-col items-center min-h-screen justify-between overflow-x-clip"
|
||||
>
|
||||
<Navbar />
|
||||
<main class="w-[95%]">
|
||||
<slot />
|
||||
|
@ -23,6 +25,9 @@ import InView from "../components/core/InView.astro";
|
|||
</body>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
background-color: rgb(10 14 26 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
|
|
20
src/pages/oauth2-redirect.astro
Normal file
20
src/pages/oauth2-redirect.astro
Normal file
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
const title = "Authenticating...";
|
||||
---
|
||||
|
||||
<Layout {title}>
|
||||
<main class="min-h-screen flex items-center justify-center">
|
||||
<div id="content" class="text-center">
|
||||
<p class="text-2xl font-medium">Redirecting to store...</p>
|
||||
<div class="mt-4">
|
||||
<div class="loading loading-spinner loading-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { RedirectHandler } from "../components/auth/RedirectHandler";
|
||||
new RedirectHandler();
|
||||
</script>
|
51
src/pages/online-store.astro
Normal file
51
src/pages/online-store.astro
Normal file
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import UserProfile from "../components/auth/UserProfile.astro";
|
||||
import StoreItem from "../components/store/StoreItem.astro";
|
||||
const title = "IEEE Store";
|
||||
---
|
||||
|
||||
<Layout {title}>
|
||||
<main class="w-[95%] mx-auto pb-12 md:pt-[14vh] pt-[12vw] min-h-screen">
|
||||
<h1 class="text-4xl font-bold mb-12">IEEE UCSD Store</h1>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<!-- Left Column - User Info -->
|
||||
<div class="md:col-span-1 h-fit">
|
||||
<UserProfile />
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Store Items -->
|
||||
<div id="storeContent" class="md:col-span-3">
|
||||
<div
|
||||
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
<StoreItem
|
||||
name="Item Name"
|
||||
description="Description of the item goes here. This is a placeholder."
|
||||
price={20.0}
|
||||
/>
|
||||
<StoreItem
|
||||
name="Item Name"
|
||||
description="Description of the item goes here. This is a placeholder."
|
||||
price={25.0}
|
||||
/>
|
||||
<StoreItem
|
||||
name="Item Name"
|
||||
description="Description of the item goes here. This is a placeholder."
|
||||
price={15.0}
|
||||
/>
|
||||
<StoreItem
|
||||
name="Item Name"
|
||||
description="Description of the item goes here. This is a placeholder."
|
||||
price={15.0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
import { StoreAuth } from "../components/auth/StoreAuth";
|
||||
new StoreAuth();
|
||||
</script>
|
|
@ -9,7 +9,8 @@ export default {
|
|||
"animate-delay-100",
|
||||
"animate-delay-300",
|
||||
"animate-delay-500",
|
||||
"animate-delay-700"],
|
||||
"animate-delay-700",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
|
@ -35,7 +36,9 @@ export default {
|
|||
plugins: [
|
||||
require("tailwindcss-motion"),
|
||||
require("tailwindcss-animated"),
|
||||
require("daisyui"),
|
||||
function ({ addVariant }) {
|
||||
addVariant("in-view", "&.in-view");
|
||||
},],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue