commit 8d2ccf73306598e786b7620e867a2654082e897c Author: v0 Date: Wed Sep 3 08:34:49 2025 +0000 feat: unify button colors to match header gradient Consistent blue color scheme for all website buttons. Co-authored-by: Jeff Emmett <46964190+Jeff-Emmett@users.noreply.github.com> diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f650315 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/app/api/create-checkout-session/route.ts b/app/api/create-checkout-session/route.ts new file mode 100644 index 0000000..be98e7e --- /dev/null +++ b/app/api/create-checkout-session/route.ts @@ -0,0 +1,151 @@ +import { type NextRequest, NextResponse } from "next/server" +import Stripe from "stripe" + +export async function POST(request: NextRequest) { + try { + const stripeSecretKey = process.env.STRIPE_SECRET_KEY + + if (!stripeSecretKey) { + return NextResponse.json({ error: "STRIPE_SECRET_KEY environment variable is not set" }, { status: 500 }) + } + + const trimmedKey = stripeSecretKey.trim() + + // Check if it's still the placeholder value + if (trimmedKey === "sk_test_your_secret_key_here" || trimmedKey.includes("your_secret_key_here")) { + return NextResponse.json( + { + error: + "Please replace the placeholder STRIPE_SECRET_KEY with your actual Stripe secret key from your Stripe dashboard", + }, + { status: 500 }, + ) + } + + // Validate that it's a proper Stripe key format + if (!trimmedKey.startsWith("sk_test_") && !trimmedKey.startsWith("sk_live_")) { + return NextResponse.json( + { error: "Invalid Stripe key format. Must start with sk_test_ or sk_live_" }, + { status: 500 }, + ) + } + + const stripe = new Stripe(trimmedKey, { + apiVersion: "2024-06-20", + }) + + const body = await request.json() + const { lookup_key, priceId, sponsorTier, productId, productName, price, currency, description, mode } = body + + if (mode === "subscription" && productId && price) { + console.log("[v0] Creating subscription checkout for product:", productName) + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [ + { + price_data: { + currency: currency || "cad", + product_data: { + name: productName, + description: description || `Monthly subscription to support Jerry Higginson, the Alert Bay Trumpeter`, + }, + unit_amount: price, + recurring: { + interval: "month", + }, + }, + quantity: 1, + }, + ], + success_url: `${request.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${request.nextUrl.origin}/cancel`, + }) + + console.log("[v0] Subscription checkout session created successfully:", session.id) + return NextResponse.json({ url: session.url }) + } + + if (priceId === "buck_a_month" || sponsorTier === "Monthly Supporter") { + console.log("[v0] Creating monthly subscription checkout session") + + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [ + { + price_data: { + currency: "cad", + product_data: { + name: "Buck-A-Month Sponsor - Alert Bay Trumpeter", + description: "Monthly $1 CAD subscription to support Jerry Higginson, the Alert Bay Trumpeter", + }, + unit_amount: 100, // $1.00 CAD in cents + recurring: { + interval: "month", + }, + }, + quantity: 1, + }, + ], + success_url: `${request.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${request.nextUrl.origin}/cancel`, + }) + + console.log("[v0] Monthly subscription checkout session created successfully:", session.id) + return NextResponse.json({ url: session.url }) + } + + if (!lookup_key) { + return NextResponse.json({ error: "Missing lookup_key for sponsor tier" }, { status: 400 }) + } + + // Define price mapping for sponsor tiers + const priceMap: { [key: string]: number } = { + copper: 1000, // $10.00 in cents + bronze: 2500, // $25.00 in cents + silver: 5000, // $50.00 in cents + gold: 10000, // $100.00 in cents + } + + const tierNames: { [key: string]: string } = { + copper: "Copper Sponsor", + bronze: "Bronze Sponsor", + silver: "Silver Sponsor", + gold: "Gold Sponsor", + } + + const amount = priceMap[lookup_key] + const tierName = tierNames[lookup_key] + + if (!amount || !tierName) { + return NextResponse.json({ error: "Invalid sponsor tier" }, { status: 400 }) + } + + console.log("[v0] Creating checkout session for:", tierName, "Amount:", amount) + + const session = await stripe.checkout.sessions.create({ + mode: "payment", + line_items: [ + { + price_data: { + currency: "cad", + product_data: { + name: `${tierName} - Alert Bay Trumpeter`, + description: `Support Jerry Higginson, the Alert Bay Trumpeter, with a ${tierName.toLowerCase()} donation.`, + }, + unit_amount: amount, + }, + quantity: 1, + }, + ], + success_url: `${request.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${request.nextUrl.origin}/cancel`, + }) + + console.log("[v0] Checkout session created successfully:", session.id) + return NextResponse.json({ url: session.url }) + } catch (error: any) { + console.error("[v0] Error creating checkout session:", error.message) + return NextResponse.json({ error: error.message }, { status: 500 }) + } +} diff --git a/app/api/create-portal-session/route.ts b/app/api/create-portal-session/route.ts new file mode 100644 index 0000000..4c2bd9b --- /dev/null +++ b/app/api/create-portal-session/route.ts @@ -0,0 +1,28 @@ +import { type NextRequest, NextResponse } from "next/server" +import Stripe from "stripe" + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2024-06-20", +}) + +export async function POST(request: NextRequest) { + try { + const { session_id } = await request.json() + + const checkoutSession = await stripe.checkout.sessions.retrieve(session_id) + + if (!checkoutSession.customer) { + return NextResponse.json({ error: "No customer found" }, { status: 400 }) + } + + const portalSession = await stripe.billingPortal.sessions.create({ + customer: checkoutSession.customer as string, + return_url: `${request.nextUrl.origin}`, + }) + + return NextResponse.json({ url: portalSession.url }) + } catch (error: any) { + console.error("Error creating portal session:", error) + return NextResponse.json({ error: { message: error.message } }, { status: 500 }) + } +} diff --git a/app/api/create-product-checkout/route.ts b/app/api/create-product-checkout/route.ts new file mode 100644 index 0000000..fe01790 --- /dev/null +++ b/app/api/create-product-checkout/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server" +import Stripe from "stripe" + +export async function POST(request: Request) { + try { + const { priceId } = await request.json() + + if (!priceId) { + return NextResponse.json({ error: "Price ID is required" }, { status: 400 }) + } + + const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() + + if (!stripeSecretKey) { + return NextResponse.json({ error: "Stripe configuration missing" }, { status: 500 }) + } + + if (stripeSecretKey === "sk_test_your_secret_key_here" || stripeSecretKey.includes("your_secret_key_here")) { + return NextResponse.json({ error: "Please configure your actual Stripe secret key" }, { status: 500 }) + } + + const stripe = new Stripe(stripeSecretKey, { + apiVersion: "2024-06-20", + }) + + const origin = request.headers.get("origin") + const baseUrl = origin || `https://${request.headers.get("host")}` + + if (!baseUrl || (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://"))) { + console.error("[v0] Invalid origin/host for URL construction:", { origin, host: request.headers.get("host") }) + return NextResponse.json({ error: "Unable to construct return URLs" }, { status: 500 }) + } + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ["card"], + line_items: [ + { + price: priceId, + quantity: 1, + }, + ], + mode: "payment", + success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${baseUrl}/cancel`, + }) + + return NextResponse.json({ url: session.url }) + } catch (error) { + console.error("[v0] Error creating product checkout session:", error) + return NextResponse.json({ error: "Failed to create checkout session" }, { status: 500 }) + } +} diff --git a/app/api/products/route.ts b/app/api/products/route.ts new file mode 100644 index 0000000..5f254e3 --- /dev/null +++ b/app/api/products/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server" +import Stripe from "stripe" + +export async function GET() { + try { + const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() + + if (!stripeSecretKey) { + return NextResponse.json({ error: "Stripe configuration missing" }, { status: 500 }) + } + + if (stripeSecretKey === "sk_test_your_secret_key_here" || stripeSecretKey.includes("your_secret_key_here")) { + return NextResponse.json({ error: "Please configure your actual Stripe secret key" }, { status: 500 }) + } + + const stripe = new Stripe(stripeSecretKey, { + apiVersion: "2024-06-20", + }) + + // Fetch products and their prices + const products = await stripe.products.list({ + active: true, + expand: ["data.default_price"], + }) + + // Filter for one-time payment products (not recurring subscriptions) + const oneTimeProducts = products.data.filter((product) => { + const price = product.default_price as Stripe.Price + return price && price.type === "one_time" + }) + + const productsWithPrices = oneTimeProducts.map((product) => { + const price = product.default_price as Stripe.Price + return { + id: product.id, + name: product.name, + description: product.description, + images: product.images, + price: { + id: price.id, + amount: price.unit_amount, + currency: price.currency, + }, + } + }) + + return NextResponse.json({ products: productsWithPrices }) + } catch (error) { + console.error("[v0] Error fetching products:", error) + return NextResponse.json({ error: "Failed to fetch products" }, { status: 500 }) + } +} diff --git a/app/api/subscription-products/route.ts b/app/api/subscription-products/route.ts new file mode 100644 index 0000000..442f1ea --- /dev/null +++ b/app/api/subscription-products/route.ts @@ -0,0 +1,64 @@ +import { type NextRequest, NextResponse } from "next/server" +import Stripe from "stripe" + +export async function GET(request: NextRequest) { + try { + const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() + + if (!stripeSecretKey) { + return NextResponse.json({ error: "STRIPE_SECRET_KEY environment variable is not set" }, { status: 500 }) + } + + if (stripeSecretKey === "sk_test_your_secret_key_here" || stripeSecretKey.includes("your_secret_key_here")) { + return NextResponse.json( + { + error: + "Please replace the placeholder STRIPE_SECRET_KEY with your actual Stripe secret key from your Stripe dashboard", + }, + { status: 500 }, + ) + } + + const stripe = new Stripe(stripeSecretKey, { + apiVersion: "2024-06-20", + }) + + console.log("[v0] Fetching Stripe subscription products...") + + // Fetch all products + const products = await stripe.products.list({ + active: true, + expand: ["data.default_price"], + }) + + // Filter for subscription products and sort by price + const subscriptionProducts = products.data + .filter((product) => { + const price = product.default_price as Stripe.Price + return price && price.type === "recurring" && price.recurring?.interval === "month" + }) + .map((product) => { + const price = product.default_price as Stripe.Price + return { + id: product.id, + name: product.name, + description: product.description, + price: price.unit_amount, + currency: price.currency, + lookup_key: price.lookup_key, + images: product.images, + } + }) + .sort((a, b) => (a.price || 0) - (b.price || 0)) + + console.log("[v0] Found subscription products:", subscriptionProducts.length) + + return NextResponse.json({ products: subscriptionProducts }) + } catch (error) { + console.error("[v0] Error fetching subscription products:", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Failed to fetch subscription products" }, + { status: 500 }, + ) + } +} diff --git a/app/api/webhook/route.ts b/app/api/webhook/route.ts new file mode 100644 index 0000000..a32c533 --- /dev/null +++ b/app/api/webhook/route.ts @@ -0,0 +1,44 @@ +import { type NextRequest, NextResponse } from "next/server" +import Stripe from "stripe" + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: "2024-06-20", +}) + +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET! + +export async function POST(request: NextRequest) { + try { + const body = await request.text() + const signature = request.headers.get("stripe-signature")! + + let event: Stripe.Event + + try { + event = stripe.webhooks.constructEvent(body, signature, webhookSecret) + } catch (err: any) { + console.log(`⚠️ Webhook signature verification failed.`, err.message) + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }) + } + + // Handle the event + switch (event.type) { + case "checkout.session.completed": + const session = event.data.object as Stripe.Checkout.Session + console.log(`💰 Payment successful for session: ${session.id}`) + // Handle successful payment here + break + case "payment_intent.succeeded": + const paymentIntent = event.data.object as Stripe.PaymentIntent + console.log(`💰 Payment succeeded: ${paymentIntent.id}`) + break + default: + console.log(`Unhandled event type ${event.type}`) + } + + return NextResponse.json({ received: true }) + } catch (error: any) { + console.error("Webhook error:", error) + return NextResponse.json({ error: { message: error.message } }, { status: 500 }) + } +} diff --git a/app/cancel/page.tsx b/app/cancel/page.tsx new file mode 100644 index 0000000..dc760eb --- /dev/null +++ b/app/cancel/page.tsx @@ -0,0 +1,24 @@ +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Card, CardContent } from "@/components/ui/card" +import { XCircle } from "lucide-react" + +export default function CancelPage() { + return ( +
+ + + +

Donation Cancelled

+

+ No worries! You can always come back to support Jerry and the Alert Bay Trumpeter mission later. +

+ + + + +
+
+
+ ) +} diff --git a/app/contact/page.tsx b/app/contact/page.tsx new file mode 100644 index 0000000..a686210 --- /dev/null +++ b/app/contact/page.tsx @@ -0,0 +1,231 @@ +"use client" + +import Image from "next/image" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { useState } from "react" + +export default function ContactPage() { + const [isLoading, setIsLoading] = useState(false) + + const handleMonthlySponsorship = async () => { + setIsLoading(true) + try { + console.log("[v0] Starting monthly sponsorship process") + + const response = await fetch("/api/create-checkout-session", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + priceId: "buck_a_month", + sponsorTier: "Monthly Supporter", + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || "Failed to create checkout session") + } + + const data = await response.json() + + // Open Stripe checkout in new tab to avoid ad blocker issues + window.open(data.url, "_blank") + } catch (error) { + console.error("Error creating checkout session:", error) + alert("There was an error processing your request. Please try again.") + } finally { + setIsLoading(false) + } + } + + return ( +
+ {/* Header */} +
+
+

Welcome to

+

AlertBayTrumpeter.com!

+ + {/* Navigation */} + +
+
+ + {/* Contact Content */} +
+
+

Get in Touch

+ +
+ {/* Contact Form */} + + + Send Jerry a Message + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +