diff --git a/.env.local.example b/.env.local.example index d76b6cee..648e6d03 100644 --- a/.env.local.example +++ b/.env.local.example @@ -18,3 +18,7 @@ UPSTASH_REDIS_REST_TOKEN= # Optional: WalletConnect project ID # NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID= + +# Optional: Resend API key for feedback emails +# RESEND_API_KEY= +# RESEND_FROM_EMAIL=Certified diff --git a/package-lock.json b/package-lock.json index b673b473..fa65e98c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "next": "^16.1.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "resend": "^6.10.0", "viem": "^2.46.3", "wagmi": "^2.19.5" }, @@ -3528,6 +3529,12 @@ "node": ">=20" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "license": "Apache-2.0", @@ -6355,6 +6362,12 @@ "version": "2.1.1", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-stable-stringify": { "version": "1.0.0", "license": "MIT" @@ -8341,6 +8354,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.6", "dev": true, @@ -8718,6 +8737,27 @@ "version": "2.0.0", "license": "ISC" }, + "node_modules/resend": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.10.0.tgz", + "integrity": "sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "svix": "1.88.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "dev": true, @@ -9591,6 +9631,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "dev": true, @@ -9849,6 +9899,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.88.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.88.0.tgz", + "integrity": "sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "dev": true, diff --git a/package.json b/package.json index a0a2867b..52c2a102 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "next": "^16.1.6", "react": "^19.0.0", "react-dom": "^19.0.0", + "resend": "^6.10.0", "viem": "^2.46.3", "wagmi": "^2.19.5" }, diff --git a/src/app/api/feedback/route.ts b/src/app/api/feedback/route.ts new file mode 100644 index 00000000..3ebd9ff5 --- /dev/null +++ b/src/app/api/feedback/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { Resend } from "resend"; + +const resend = new Resend(process.env.RESEND_API_KEY); +const SUPPORT_EMAIL = "support@hypercerts.org"; +const FROM_EMAIL = process.env.RESEND_FROM_EMAIL || "Certified "; + +export async function POST(req: NextRequest) { + try { + const { message, email } = await req.json(); + + if (!message || typeof message !== "string" || !message.trim()) { + return NextResponse.json({ error: "Message is required" }, { status: 400 }); + } + + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return NextResponse.json({ error: "Invalid email address" }, { status: 400 }); + } + + // Send feedback to support + await resend.emails.send({ + from: FROM_EMAIL, + to: SUPPORT_EMAIL, + subject: "New feedback for Certified.app", + text: [ + "New feedback received:", + "", + message, + "", + email ? `Reply-to: ${email}` : "No email provided", + ].join("\n"), + ...(email ? { replyTo: email } : {}), + }); + + // Send confirmation to user if they provided an email + if (email) { + await resend.emails.send({ + from: FROM_EMAIL, + to: email, + subject: "Thank you for your feedback to Certified.app", + text: [ + "Thank you for sharing your feedback with us!", + "", + "We've received the following message:", + "", + `"${message}"`, + "", + "We appreciate your input and will review it carefully.", + "", + "Best regards,", + "The Certified Team", + ].join("\n"), + }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Feedback error:", error); + return NextResponse.json({ error: "Failed to send feedback" }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index a0f43238..9e2c629d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -171,7 +171,7 @@ h1, h2, h3, h4, h5, h6 { .navbar__logo { display: flex; - align-items: center; + align-items: flex-end; text-decoration: none; } @@ -180,6 +180,19 @@ h1, h2, h3, h4, h5, h6 { display: block; } +.navbar__beta-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-dark-gray); + background: var(--color-gray-100); + padding: 1px 6px; + border-radius: 4px; + margin-left: 10px; + line-height: 1; +} + .navbar__right { display: flex; align-items: center; @@ -827,6 +840,254 @@ h1, h2, h3, h4, h5, h6 { } } +/* ========== Feedback Modal ========== */ +.feedback-trigger { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 999; + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: var(--color-primary); + color: var(--color-white); + border: none; + border-radius: var(--radius); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); + transition: background var(--transition-fast), box-shadow var(--transition-fast), bottom 0.15s ease-out; +} + +.feedback-trigger:hover { + background: var(--color-accent); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +} + +.feedback-modal__backdrop { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: flex-end; + justify-content: flex-end; + padding: 20px; + background: var(--navy-overlay-70); + backdrop-filter: blur(8px); + animation: modalFadeIn 200ms ease-out; +} + +.feedback-modal { + width: 420px; + max-width: 90vw; + max-height: calc(100vh - 40px); + overflow-y: auto; + background: var(--color-white); + border-radius: var(--radius); + box-shadow: 0 24px 64px var(--navy-overlay-30); + border: 1px solid var(--border-default); + animation: modalSlideUp 300ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.feedback-modal--expanded { + width: 640px; +} + +.feedback-modal--expanded .feedback-modal__textarea { + min-height: 400px; +} + +.feedback-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--color-gray-100); +} + +.feedback-modal__expand { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: var(--radius); + color: var(--color-mid-gray); + cursor: pointer; + margin-right: 8px; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.feedback-modal__expand:hover { + background: var(--color-gray-100); + color: var(--color-primary); +} + +.feedback-modal__title { + font-size: 0.875rem; + font-weight: 600; + color: var(--color-primary); +} + +.feedback-modal__close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + border-radius: var(--radius); + color: var(--color-mid-gray); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.feedback-modal__close:hover { + background: var(--color-gray-100); + color: var(--color-primary); +} + +.feedback-modal__body { + padding: 24px; +} + +.feedback-modal__label { + display: block; + font-size: 0.8125rem; + color: var(--color-dark-gray); + margin-bottom: 8px; + line-height: 1.4; +} + +.feedback-modal__label--email { + margin-top: 16px; +} + +.feedback-modal__textarea { + width: 100%; + min-height: 120px; + padding: 12px; + border: 1px solid var(--color-light-gray); + border-radius: var(--radius); + font-size: 0.875rem; + font-family: var(--font-inter); + color: var(--color-primary); + background: var(--color-white); + resize: vertical; + transition: border-color var(--transition-fast); +} + +.feedback-modal__textarea:focus { + outline: none; + border-color: var(--color-accent); +} + +.feedback-modal__input { + width: 100%; + height: 44px; + padding: 0 12px; + border: 1px solid var(--color-light-gray); + border-radius: var(--radius); + font-size: 0.875rem; + font-family: var(--font-inter); + color: var(--color-primary); + background: var(--color-white); + transition: border-color var(--transition-fast); +} + +.feedback-modal__input:focus { + outline: none; + border-color: var(--color-accent); +} + +.feedback-modal__error { + font-size: 0.75rem; + color: var(--color-error); + margin-top: 4px; +} + +.feedback-modal__submit { + width: 100%; + height: 44px; + margin-top: 20px; + background: var(--color-primary); + color: var(--color-white); + border: none; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.feedback-modal__submit:hover:not(:disabled) { + background: var(--color-accent); +} + +.feedback-modal__submit:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.feedback-modal__success { + text-align: center; + padding: 20px 0; +} + +.feedback-modal__success p { + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-primary); + margin-bottom: 16px; +} + +.feedback-modal__done { + padding: 8px 24px; + background: var(--color-primary); + color: var(--color-white); + border: none; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background var(--transition-fast); +} + +.feedback-modal__done:hover { + background: var(--color-accent); +} + +@media (max-width: 768px) { + .feedback-trigger { + bottom: 16px; + right: 16px; + padding: 8px 12px; + font-size: 0.75rem; + } + + .feedback-modal__backdrop { + align-items: flex-end; + justify-content: center; + padding: 0; + } + + .feedback-modal, + .feedback-modal--expanded { + width: 100vw; + max-width: none; + border-radius: 16px 16px 0 0; + } + + .feedback-modal__expand { + display: none; + } +} + /* ========== App Pages (authenticated) ========== */ .app-page { padding-top: var(--navbar-height); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6fb8d23f..be1e6102 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import Footer from "@/components/layout/footer"; import { Providers } from "@/lib/providers"; import AppShell from "@/components/layout/app-shell"; import { OrgProvider } from "@/lib/organizations/org-context"; +import FeedbackModal from "@/components/ui/feedback-modal"; const inter = Inter({ subsets: ["latin"], @@ -51,6 +52,7 @@ export default function RootLayout({ {children}