From 72cb6ecb497c4849f0363abfbc2d5cd7a7b168e9 Mon Sep 17 00:00:00 2001 From: chark1es Date: Fri, 24 Jan 2025 04:00:29 -0800 Subject: [PATCH] Add online store --- astro.config.mjs | 8 +- bun.lock | 12 + package.json | 2 + src/components/auth/RedirectHandler.ts | 85 ++++++ src/components/auth/StoreAuth.ts | 382 +++++++++++++++++++++++++ src/components/auth/UserProfile.astro | 193 +++++++++++++ src/components/store/StoreItem.astro | 37 +++ src/layouts/Layout.astro | 7 +- src/pages/oauth2-redirect.astro | 20 ++ src/pages/online-store.astro | 51 ++++ tailwind.config.mjs | 7 +- 11 files changed, 797 insertions(+), 7 deletions(-) create mode 100644 src/components/auth/RedirectHandler.ts create mode 100644 src/components/auth/StoreAuth.ts create mode 100644 src/components/auth/UserProfile.astro create mode 100644 src/components/store/StoreItem.astro create mode 100644 src/pages/oauth2-redirect.astro create mode 100644 src/pages/online-store.astro diff --git a/astro.config.mjs b/astro.config.mjs index 17e7024..8f74173 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -15,9 +15,9 @@ import icon from "astro-icon"; // https://astro.build/config export default defineConfig({ - integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()], + integrations: [tailwind(), expressiveCode(), react(), icon(), mdx()], - adapter: node({ - mode: "standalone", - }), + adapter: node({ + mode: "standalone", + }), }); diff --git a/bun.lock b/bun.lock index f94e971..282fbe1 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index 523d7b7..45d74f2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/auth/RedirectHandler.ts b/src/components/auth/RedirectHandler.ts new file mode 100644 index 0000000..4482f4e --- /dev/null +++ b/src/components/auth/RedirectHandler.ts @@ -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 = `

${message}

`; + } + + 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 = ` +

Authentication Successful!

+

Redirecting to store...

+
+
+
+ `; + + 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}`); + } + } +} \ No newline at end of file diff --git a/src/components/auth/StoreAuth.ts b/src/components/auth/StoreAuth.ts new file mode 100644 index 0000000..172a7f8 --- /dev/null +++ b/src/components/auth/StoreAuth.ts @@ -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(); + }); + } +} \ No newline at end of file diff --git a/src/components/auth/UserProfile.astro b/src/components/auth/UserProfile.astro new file mode 100644 index 0000000..69544ff --- /dev/null +++ b/src/components/auth/UserProfile.astro @@ -0,0 +1,193 @@ +
+ +
+
+
+ +

+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + + +
+ + diff --git a/src/components/store/StoreItem.astro b/src/components/store/StoreItem.astro new file mode 100644 index 0000000..cb43edc --- /dev/null +++ b/src/components/store/StoreItem.astro @@ -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; +--- + +
+
+
+
+ {name} +
+
+
+

{name}

+

{description}

+
+ ${price.toFixed(2)} + +
+
+
diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 9c9a4ac..b8e56c6 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -14,7 +14,9 @@ import InView from "../components/core/InView.astro"; Astro Basics - +
@@ -23,6 +25,9 @@ import InView from "../components/core/InView.astro";