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>
This commit is contained in:
v0 2025-09-03 08:34:49 +00:00
commit 8d2ccf7330
47 changed files with 7279 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@ -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

View File

@ -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 })
}
}

View File

@ -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 })
}
}

View File

@ -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 })
}
}

52
app/api/products/route.ts Normal file
View File

@ -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 })
}
}

View File

@ -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 },
)
}
}

44
app/api/webhook/route.ts Normal file
View File

@ -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 })
}
}

24
app/cancel/page.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<Card className="max-w-md w-full">
<CardContent className="p-8 text-center">
<XCircle className="w-16 h-16 text-gray-400 mx-auto mb-6" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">Donation Cancelled</h1>
<p className="text-gray-600 mb-6">
No worries! You can always come back to support Jerry and the Alert Bay Trumpeter mission later.
</p>
<Link href="/">
<Button className="w-full">Return to Home</Button>
</Link>
</CardContent>
</Card>
</div>
)
}

231
app/contact/page.tsx Normal file
View File

@ -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 (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-gradient-to-r from-blue-800 to-blue-600 py-6 px-6 shadow-lg">
<div className="max-w-6xl mx-auto text-center">
<h1 className="text-2xl font-serif font-bold text-white mb-2">Welcome to</h1>
<h2 className="text-4xl font-serif font-bold text-white">AlertBayTrumpeter.com!</h2>
{/* Navigation */}
<nav className="mt-6 flex justify-center space-x-8 text-sm">
<Link
href="/"
className="text-white hover:text-blue-200 font-semibold hover:underline transition-all duration-200"
>
Home
</Link>
<Link
href="/jerry-story"
className="text-white hover:text-blue-200 font-semibold hover:underline transition-all duration-200"
>
Jerry's Story
</Link>
<Link
href="/masks-art"
className="text-white hover:text-blue-200 font-semibold hover:underline transition-all duration-200"
>
Traditional Indigenous Masks & Art
</Link>
<Link href="/contact" className="text-blue-200 font-bold underline">
Get in Touch
</Link>
</nav>
</div>
</header>
{/* Contact Content */}
<section className="py-16 px-6 bg-white">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-serif font-bold text-slate-700 mb-8 text-center">Get in Touch</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Contact Form */}
<Card className="shadow-xl border-2 border-blue-100 bg-gradient-to-b from-white to-blue-50">
<CardHeader>
<CardTitle className="text-2xl text-slate-700">Send Jerry a Message</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-700 mb-1">
Name
</label>
<Input id="name" placeholder="Your name" />
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-1">
Email
</label>
<Input id="email" type="email" placeholder="your.email@example.com" />
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium text-slate-700 mb-1">
Subject
</label>
<Input id="subject" placeholder="Message subject" />
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-slate-700 mb-1">
Message
</label>
<Textarea id="message" placeholder="Your message to Jerry..." rows={6} />
</div>
<Button className="w-full bg-blue-800 hover:bg-blue-600 shadow-lg hover:shadow-xl transition-all duration-300">
Send Message
</Button>
</CardContent>
</Card>
{/* Contact Information */}
<div className="space-y-8">
<div>
<h3 className="text-2xl font-semibold text-slate-700 mb-4">About Jerry</h3>
<div className="w-full h-64 relative mb-4">
<Image
src="/images/jerry-boat.avif"
alt="Jerry Higginson on his boat with trumpet"
fill
className="object-cover rounded-lg shadow-lg"
/>
</div>
<p className="text-slate-600 leading-relaxed">
Jerry Higginson has been entertaining cruise ship passengers in Alert Bay for over 27 years. His
passion for music and dedication to spreading joy has made him a beloved figure in the maritime
community.
</p>
</div>
<div>
<h3 className="text-xl font-semibold text-slate-700 mb-4">Support Jerry's Mission</h3>
<p className="text-slate-600 leading-relaxed mb-4">
Jerry Higginson has been entertaining cruise ship passengers in Alert Bay for over 27 years. His
passion for music and dedication to spreading joy has made him a beloved figure in the maritime
community. Help support Jerry's mission to spread smiles at sea by making a donation. Your
contribution helps Jerry continue his musical serenades for cruise ship passengers.
</p>
<Button
onClick={handleMonthlySponsorship}
disabled={isLoading}
className="bg-blue-800 hover:bg-blue-600 shadow-lg hover:shadow-xl transition-all duration-300 disabled:opacity-50"
>
{isLoading ? "Processing..." : "Support Jerry - $1/Month"}
</Button>
</div>
<div>
<h3 className="text-xl font-semibold text-slate-700 mb-4">Visit Alert Bay</h3>
<p className="text-slate-600 leading-relaxed">
Alert Bay is located on Cormorant Island in British Columbia, Canada. The community is rich in
Indigenous culture and maritime heritage.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Footer with Jerry's contact information */}
<footer className="bg-blue-900 text-white py-12 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 text-center md:text-left">
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Contact Jerry</h3>
<div className="space-y-3">
<div className="flex flex-col md:flex-row md:items-center justify-center md:justify-start">
<span className="font-semibold mb-1 md:mb-0 md:mr-2">Email:</span>
<a href="mailto:alertbaytrumpeter@icloud.com" className="hover:text-blue-300 transition-colors">
alertbaytrumpeter@icloud.com
</a>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-center md:justify-start">
<span className="font-semibold mb-1 md:mb-0 md:mr-2">Phone:</span>
<a href="tel:250-641-6204" className="hover:text-blue-300 transition-colors">
250 - 641- 6204
</a>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Location</h3>
<div className="text-blue-100 leading-relaxed">
<p>Hyde Creek Rd</p>
<p>British Columbia V0N 2R0</p>
<p>Canada</p>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Alert Bay Trumpeter</h3>
<p className="text-blue-100">Spreading smiles at sea since 1996</p>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 sm:gap-6">
<Link href="/jerry-story" className="hover:text-blue-300 transition-colors">
Jerry's Story
</Link>
<Link href="/masks-art" className="hover:text-blue-300 transition-colors">
Art Collection
</Link>
<Link href="/contact" className="hover:text-blue-300 transition-colors">
Contact
</Link>
</div>
</div>
</div>
<div className="border-t border-blue-800 mt-12 pt-8 text-center">
<p className="text-blue-200">© 2024 Alert Bay Trumpeter. All rights reserved.</p>
</div>
</div>
</footer>
</div>
)
}

130
app/globals.css Normal file
View File

@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--font-inter: "Inter", sans-serif;
--font-playfair: "Playfair Display", serif;
--font-geist-mono: "Geist Mono", monospace;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
/* Updated font variables to use new stylish fonts */
--font-sans: var(--font-inter);
--font-serif: var(--font-playfair);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

167
app/jerry-story/page.tsx Normal file
View File

@ -0,0 +1,167 @@
import Link from "next/link"
export default function JerryStoryPage() {
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-gradient-to-r from-blue-800 to-blue-600 py-6 px-6 shadow-lg">
<div className="max-w-6xl mx-auto text-center">
<h1 className="text-2xl font-serif font-bold text-white mb-2">Welcome to</h1>
<h2 className="text-4xl font-serif font-bold text-white">AlertBayTrumpeter.com!</h2>
{/* Navigation */}
<nav className="mt-6 flex justify-center space-x-8 text-sm">
<Link
href="/"
className="text-white hover:text-blue-200 font-semibold hover:underline transition-all duration-200"
>
Home
</Link>
<Link href="/jerry-story" className="text-blue-200 font-bold underline">
Jerry's Story
</Link>
<Link
href="/masks-art"
className="text-white hover:text-blue-200 font-semibold hover:underline transition-all duration-200"
>
Traditional Indigenous Masks & Art
</Link>
<Link
href="/contact"
className="text-white hover:text-blue-200 font-semibold hover:underline transition-all duration-200"
>
Get in Touch
</Link>
</nav>
</div>
</header>
{/* Jerry's Story Content */}
<section className="py-16 px-6 bg-slate-700">
<div className="max-w-4xl mx-auto">
<h1 className="text-4xl font-serif font-bold text-white mb-8 text-center">Jerry's Story</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-12">
<div className="rounded-lg overflow-hidden shadow-lg">
<img
src="/images/jerry-portrait.jpeg"
alt="Jerry Higginson smiling warmly"
className="w-full h-64 object-cover"
/>
</div>
<div className="rounded-lg overflow-hidden shadow-lg">
<img
src="/images/jerry-with-mask.jpeg"
alt="Jerry with traditional Indigenous mask"
className="w-full h-64 object-cover"
/>
</div>
<div className="rounded-lg overflow-hidden shadow-lg">
<img
src="/images/jerry-on-boat.jpeg"
alt="Jerry on his boat with beautiful coastline"
className="w-full h-64 object-cover"
/>
</div>
</div>
<div className="max-w-3xl mx-auto space-y-6">
<p className="text-white leading-relaxed">
Jerry Higginson has been the Alert Bay Trumpeter for over 27 years, bringing joy and music to cruise ship
passengers traveling through the beautiful waters of northern Vancouver Island.
</p>
<p className="text-white leading-relaxed">
Since 1996, Jerry has performed over 1000 nautical serenades, creating unforgettable memories for millions
of passengers from around the world. His dedication to spreading happiness through music has made him a
beloved figure in the maritime community.
</p>
<p className="text-white leading-relaxed">
From his small boat, Jerry greets each passing cruise ship with enthusiasm and his signature trumpet
performances, embodying the spirit of Alert Bay and the warmth of Canadian hospitality.
</p>
<p className="text-white leading-relaxed">
Jerry's mission is simple: to bring smiles to people's faces and create magical moments at sea. His
passion for music and connection with travelers has made him an integral part of the cruise experience
through the Johnstone Straits.
</p>
<div className="mt-8 pt-6 border-t border-slate-500 text-center">
<div className="mb-6">
<img
src="/images/jerry-masks-display.jpeg"
alt="Jerry's traditional Indigenous masks with cedar bark fringe"
className="w-full max-w-2xl mx-auto rounded-lg shadow-lg object-cover"
/>
</div>
<Link
href="/masks-art"
className="inline-flex items-center px-6 py-3 bg-blue-800 text-white font-semibold rounded-lg hover:bg-blue-600 transition-colors duration-300 shadow-lg hover:shadow-xl"
>
View Jerry's Indigenous Mask & Art Collection
<svg className="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
</div>
</div>
</div>
</section>
<footer className="bg-blue-900 text-white py-12 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 text-center md:text-left">
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Contact Jerry</h3>
<div className="space-y-3">
<div className="flex flex-col md:flex-row md:items-center justify-center md:justify-start">
<span className="font-semibold mb-1 md:mb-0 md:mr-2">Email:</span>
<a href="mailto:alertbaytrumpeter@icloud.com" className="hover:text-blue-300 transition-colors">
alertbaytrumpeter@icloud.com
</a>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-center md:justify-start">
<span className="font-semibold mb-1 md:mb-0 md:mr-2">Phone:</span>
<a href="tel:250-641-6204" className="hover:text-blue-300 transition-colors">
250 - 641- 6204
</a>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Location</h3>
<div className="text-blue-100 leading-relaxed">
<p>Hyde Creek Rd</p>
<p>British Columbia V0N 2R0</p>
<p>Canada</p>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Alert Bay Trumpeter</h3>
<p className="text-blue-100">Spreading smiles at sea since 1996</p>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 sm:gap-6">
<Link href="/jerry-story" className="hover:text-blue-300 transition-colors">
Jerry's Story
</Link>
<Link href="/masks-art" className="hover:text-blue-300 transition-colors">
Art Collection
</Link>
<Link href="/contact" className="hover:text-blue-300 transition-colors">
Contact
</Link>
</div>
</div>
</div>
<div className="border-t border-blue-800 mt-12 pt-8 text-center">
<p className="text-blue-200">© 2024 Alert Bay Trumpeter. All rights reserved.</p>
</div>
</div>
</footer>
</div>
)
}

36
app/layout.tsx Normal file
View File

@ -0,0 +1,36 @@
import type React from "react"
import type { Metadata } from "next"
import { Playfair_Display } from "next/font/google"
import { Inter } from "next/font/google"
import "./globals.css"
const playfairDisplay = Playfair_Display({
subsets: ["latin"],
display: "swap",
variable: "--font-playfair",
})
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
})
export const metadata: Metadata = {
title: "Alert Bay Trumpeter - Jerry Higginson",
description:
"The official website of Jerry Higginson, the Alert Bay Trumpeter, entertaining cruise ship passengers since 1996",
generator: "v0.app",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" className={`${playfairDisplay.variable} ${inter.variable}`}>
<body className="font-sans antialiased">{children}</body>
</html>
)
}

277
app/masks-art/page.tsx Normal file
View File

@ -0,0 +1,277 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
interface StripeProduct {
id: string
name: string
description: string | null
images: string[]
price: {
id: string
amount: number | null
currency: string
}
}
export default function MasksArtPage() {
const [products, setProducts] = useState<StripeProduct[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchProducts()
}, [])
const fetchProducts = async () => {
try {
const response = await fetch("/api/products")
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to fetch products")
}
setProducts(data.products)
} catch (err) {
console.error("[v0] Error fetching products:", err)
setError(err instanceof Error ? err.message : "Failed to load products")
} finally {
setLoading(false)
}
}
const handlePurchase = async (priceId: string, productName: string) => {
try {
console.log("[v0] Starting purchase for:", productName)
const response = await fetch("/api/create-product-checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ priceId }),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to create checkout session")
}
console.log("[v0] Checkout session created, redirecting to:", data.url)
// Open in new tab to avoid ad blocker issues
const newWindow = window.open(data.url, "_blank")
if (!newWindow) {
// Fallback: copy URL to clipboard
await navigator.clipboard.writeText(data.url)
alert(
"Popup blocked! The checkout URL has been copied to your clipboard. Please paste it in a new tab to complete your purchase.",
)
}
} catch (err) {
console.error("[v0] Error during purchase:", err)
alert(`Error: ${err instanceof Error ? err.message : "Failed to start checkout"}`)
}
}
const formatPrice = (amount: number | null, currency: string) => {
if (!amount) return "Price not available"
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100)
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-gradient-to-r from-blue-800 to-blue-600 py-6 px-6 shadow-lg">
<div className="max-w-6xl mx-auto text-center">
<h1 className="text-2xl font-serif font-bold text-white mb-2">Welcome to</h1>
<h2 className="text-4xl font-serif font-bold text-white">AlertBayTrumpeter.com!</h2>
{/* Navigation */}
<nav className="mt-6 flex justify-center space-x-8 text-sm">
<Link
href="/"
className="text-white hover:text-blue-200 font-semibold hover:underline transition-all duration-200"
>
Home
</Link>
<Link
href="/jerry-story"
className="text-white hover:text-blue-200 font-semibold hover:underline transition-all duration-200"
>
Jerry's Story
</Link>
<Link href="/masks-art" className="text-blue-200 font-bold underline">
Traditional Indigenous Masks & Art
</Link>
<Link
href="/contact"
className="text-white hover:text-blue-200 font-semibold hover:underline transition-all duration-200"
>
Get in Touch
</Link>
</nav>
</div>
</header>
{/* Masks & Art Content */}
<section className="py-16 px-6 bg-white">
<div className="max-w-6xl mx-auto">
<h1 className="text-4xl font-serif font-bold text-slate-700 mb-8 text-center">
Traditional Indigenous Masks & Art
</h1>
<div className="text-center mb-12 bg-slate-50 rounded-lg p-8">
<p className="text-slate-600 leading-relaxed max-w-3xl mx-auto text-lg">
Alert Bay is home to rich Indigenous culture and traditional art. Explore and purchase beautiful masks and
artwork that represent the heritage and traditions of the local First Nations communities.
</p>
</div>
{loading && (
<div className="text-center py-12">
<p className="text-slate-600">Loading products...</p>
</div>
)}
{error && (
<div className="text-center py-12">
<p className="text-red-600 mb-4">Error loading products: {error}</p>
<Button onClick={fetchProducts} className="bg-blue-800 hover:bg-blue-600">
Try Again
</Button>
</div>
)}
{!loading && !error && products.length === 0 && (
<div className="text-center py-12">
<p className="text-slate-600">No products available at this time.</p>
</div>
)}
{!loading && !error && products.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<Card
key={product.id}
className="text-center hover:shadow-xl transition-all duration-300 border-2 hover:border-blue-300 bg-gradient-to-b from-white to-blue-50"
>
<CardContent className="p-6">
<div className="w-full h-64 relative mb-4">
<Image
src={
product.images[0] || "/placeholder.svg?height=256&width=300&query=traditional indigenous art"
}
alt={product.name}
fill
className="object-cover rounded-lg shadow-md"
/>
</div>
<h3 className="text-xl font-semibold text-slate-800 mb-2">{product.name}</h3>
{product.description && <p className="text-slate-600 mb-4">{product.description}</p>}
<div className="mb-4">
<span className="text-2xl font-bold text-slate-700">
{formatPrice(product.price.amount, product.price.currency)}
</span>
</div>
<Button
onClick={() => handlePurchase(product.price.id, product.name)}
className="w-full bg-blue-800 hover:bg-blue-600 text-white shadow-lg hover:shadow-xl transition-all duration-300"
>
Purchase Now
</Button>
</CardContent>
</Card>
))}
</div>
)}
</div>
</section>
{/* Get in Touch call-to-action section */}
<section className="py-16 px-6 bg-slate-50">
<div className="max-w-4xl mx-auto text-center">
<div className="bg-white rounded-lg p-8 shadow-lg">
<h2 className="text-3xl font-serif font-bold text-slate-700 mb-4">Interested in Jerry's Art?</h2>
<p className="text-slate-600 mb-8 text-lg leading-relaxed">
Have questions about a piece or want to commission custom artwork? Jerry would love to hear from you!
</p>
<Link
href="/contact"
className="inline-flex items-center px-8 py-4 bg-blue-800 text-white font-semibold rounded-lg hover:bg-blue-600 transition-colors duration-300 shadow-lg hover:shadow-xl"
>
Get in Touch
<svg className="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
</div>
</section>
{/* Footer with Jerry's contact information */}
<footer className="bg-blue-900 text-white py-12 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 text-center md:text-left">
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Contact Jerry</h3>
<div className="space-y-3">
<div className="flex flex-col md:flex-row md:items-center justify-center md:justify-start">
<span className="font-semibold mb-1 md:mb-0 md:mr-2">Email:</span>
<a href="mailto:alertbaytrumpeter@icloud.com" className="hover:text-blue-300 transition-colors">
alertbaytrumpeter@icloud.com
</a>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-center md:justify-start">
<span className="font-semibold mb-1 md:mb-0 md:mr-2">Phone:</span>
<a href="tel:250-641-6204" className="hover:text-blue-300 transition-colors">
250 - 641- 6204
</a>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Location</h3>
<div className="text-blue-100 leading-relaxed">
<p>Hyde Creek Rd</p>
<p>British Columbia V0N 2R0</p>
<p>Canada</p>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Alert Bay Trumpeter</h3>
<p className="text-blue-100">Spreading smiles at sea since 1996</p>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 sm:gap-6">
<Link href="/jerry-story" className="hover:text-blue-300 transition-colors">
Jerry's Story
</Link>
<Link href="/masks-art" className="hover:text-blue-300 transition-colors">
Art Collection
</Link>
<Link href="/contact" className="hover:text-blue-300 transition-colors">
Contact
</Link>
</div>
</div>
</div>
<div className="border-t border-blue-800 mt-12 pt-8 text-center">
<p className="text-blue-200">© 2024 Alert Bay Trumpeter. All rights reserved.</p>
</div>
</div>
</footer>
</div>
)
}

470
app/page.tsx Normal file
View File

@ -0,0 +1,470 @@
"use client"
import Image from "next/image"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { useEffect, useState } from "react"
interface SubscriptionProduct {
id: string
name: string
description: string | null
price: number | null
currency: string
lookup_key: string | null
images: string[]
}
export default function HomePage() {
const [subscriptionProducts, setSubscriptionProducts] = useState<SubscriptionProduct[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchSubscriptionProducts()
}, [])
const fetchSubscriptionProducts = async () => {
try {
console.log("[v0] Fetching subscription products...")
const response = await fetch("/api/subscription-products")
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || "Failed to fetch products")
}
const data = await response.json()
console.log("[v0] Received subscription products:", data.products)
setSubscriptionProducts(data.products)
} catch (error) {
console.error("[v0] Error fetching subscription products:", error)
setError(error instanceof Error ? error.message : "Failed to load products")
} finally {
setLoading(false)
}
}
const handleDonate = async (product: SubscriptionProduct) => {
try {
console.log("[v0] Starting donation process for product:", product.name)
let requestBody: any
if (product.lookup_key) {
// Use lookup_key for one-time donations (legacy sponsor tiers)
requestBody = { lookup_key: product.lookup_key }
} else {
// Use product info for subscription checkouts
requestBody = {
productId: product.id,
productName: product.name,
price: product.price,
currency: product.currency,
description: product.description,
mode: "subscription",
}
}
const response = await fetch("/api/create-checkout-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `HTTP error! status: ${response.status}`)
}
const data = await response.json()
console.log("[v0] Checkout session created, opening checkout:", data.url)
if (data.url) {
const newWindow = window.open(data.url, "_blank", "noopener,noreferrer")
if (!newWindow) {
const userConfirmed = confirm(
"Please allow popups for this site to complete your donation, or click OK to copy the checkout link.",
)
if (userConfirmed) {
navigator.clipboard
.writeText(data.url)
.then(() => {
alert("Checkout link copied to clipboard! Please paste it in a new tab to complete your donation.")
})
.catch(() => {
alert(`Please visit this link to complete your donation: ${data.url}`)
})
}
}
} else if (data.error) {
throw new Error(data.error)
} else {
throw new Error("No checkout URL received from server")
}
} catch (error) {
console.error("[v0] Error in donation process:", error)
let errorMessage = "Unknown error occurred"
if (error instanceof Error) {
errorMessage = error.message
}
alert(`Error processing donation: ${errorMessage}`)
}
}
const formatPrice = (price: number | null, currency: string) => {
if (!price) return "Contact for pricing"
return new Intl.NumberFormat("en-CA", {
style: "currency",
currency: currency.toUpperCase(),
}).format(price / 100)
}
const getSponsorTierInfo = (index: number) => {
const tiers = [
{
name: "Copper",
color: "orange",
bgGradient: "from-white to-orange-50",
borderColor: "orange-300",
buttonColor: "orange-600",
},
{
name: "Bronze",
color: "amber",
bgGradient: "from-white to-amber-50",
borderColor: "amber-400",
buttonColor: "amber-600",
},
{
name: "Silver",
color: "gray",
bgGradient: "from-white to-gray-50",
borderColor: "gray-400",
buttonColor: "gray-600",
},
{
name: "Gold",
color: "yellow",
bgGradient: "from-white to-yellow-50",
borderColor: "yellow-400",
buttonColor: "yellow-600",
},
]
return tiers[index] || tiers[0]
}
const getSponsorImage = (index: number) => {
const images = [
"/images/copper-sponsor.avif",
"/images/gold-sponsor.avif",
"/images/silver-sponsor.avif",
"/images/bronze-sponsor.avif",
]
return images[index] || images[0]
}
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="bg-gradient-to-r from-blue-800 to-blue-600 py-6 px-6 shadow-lg">
<div className="max-w-6xl mx-auto text-center">
<h1 className="text-2xl font-serif font-bold text-white mb-2">Welcome to</h1>
<h2 className="text-4xl font-serif font-bold text-white">AlertBayTrumpeter.com!</h2>
{/* Navigation */}
<nav className="mt-8 flex justify-center space-x-8 text-base">
<Link
href="/"
className="text-blue-100 hover:text-white font-bold transition-colors duration-200 border-b-2 border-transparent hover:border-white pb-1"
>
Home
</Link>
<Link
href="/jerry-story"
className="text-blue-100 hover:text-white font-bold transition-colors duration-200 border-b-2 border-transparent hover:border-white pb-1"
>
Jerry's Story
</Link>
<Link
href="/masks-art"
className="text-blue-100 hover:text-white font-bold transition-colors duration-200 border-b-2 border-transparent hover:border-white pb-1"
>
Traditional Indigenous Masks & Art
</Link>
<Link
href="/contact"
className="text-blue-100 hover:text-white font-bold transition-colors duration-200 border-b-2 border-transparent hover:border-white pb-1"
>
Get in Touch
</Link>
</nav>
</div>
</header>
{/* Hero Section */}
<section className="relative">
<div className="w-full h-96 relative">
<Image
src="/images/jerry-hero.avif"
alt="Jerry Higginson on boat with Alert Bay Trumpeter flag"
fill
className="object-cover"
/>
</div>
</section>
{/* Jerry's Story Section */}
<section className="bg-blue-800 text-white py-16 px-6 text-center">
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
<div>
<h2 className="text-4xl font-serif font-bold mb-6">
The Story of Jerry Higginson, the Alert Bay Trumpeter
</h2>
<Button
variant="outline"
className="text-blue-800 bg-white hover:bg-blue-50 border-white hover:border-blue-100"
>
Learn more about Jerry's Story
</Button>
</div>
<div>
<h3 className="text-2xl font-semibold mb-4">
Over 1000 nautical serenades to cruise ship passengers since 1996
</h3>
<p className="text-blue-100 mb-4">
If you've ever cruised through the Johnstone Straits of northern Vancouver Island, you've probably met
Jerry Higginson. Jerry has been energetically entertaining passengers of his hometown of Alert Bay for
over 27 years, to the delight of millions.
</p>
<p className="text-blue-100">Donate to support Jerry's mission to spread smiles at sea!</p>
</div>
</div>
</section>
{/* Support Musicians Section */}
<section className="py-12 px-6">
<div className="max-w-6xl mx-auto text-center">
<h2 className="text-4xl font-serif font-bold text-blue-600 mb-12">Support Independent Artists at Sea!</h2>
<div className="max-w-4xl mx-auto mb-12">
<p className="text-gray-600 mb-4">
Donations to the Alert Bay Trumpeter go to support Jerry in his mission to spread smiles at sea by
serenading cruise ship passengers with his trumpet.
</p>
</div>
{/* Sponsor Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{loading ? (
<div className="col-span-full text-center py-8">
<p className="text-gray-600">Loading sponsorship options...</p>
</div>
) : error ? (
<div className="col-span-full text-center py-8">
<p className="text-red-600">Error loading sponsorship options: {error}</p>
</div>
) : subscriptionProducts.length === 0 ? (
<div className="col-span-full text-center py-8">
<p className="text-gray-600">No sponsorship options available at this time.</p>
</div>
) : (
subscriptionProducts.slice(0, 4).map((product, index) => {
const tierInfo = getSponsorTierInfo(index)
return (
<Card
key={product.id}
className={`text-center hover:shadow-xl transition-all duration-300 border-2 hover:border-${tierInfo.borderColor} bg-gradient-to-b ${tierInfo.bgGradient}`}
>
<CardContent className="p-6">
<div className="w-full h-48 relative mb-6 rounded-lg overflow-hidden shadow-md">
<Image
src={product.images[0] || getSponsorImage(index)}
alt={`${product.name} sponsor`}
fill
className="object-contain hover:scale-105 transition-transform duration-300"
/>
</div>
<h3 className={`text-xl font-bold text-${tierInfo.color}-600 mb-3`}>{product.name}</h3>
<p className="text-2xl font-semibold text-gray-800 mb-4">
{formatPrice(product.price, product.currency)}/month
</p>
{product.description && <p className="text-sm text-gray-600 mb-4">{product.description}</p>}
<Button
onClick={() => handleDonate(product)}
className="w-full bg-blue-800 hover:bg-blue-600 shadow-lg hover:shadow-xl transition-all duration-300"
>
Subscribe Now
</Button>
</CardContent>
</Card>
)
})
)}
</div>
</div>
</section>
{/* Videos Section */}
<section className="py-16 px-6 bg-blue-800">
<div className="max-w-6xl mx-auto text-center">
<h2 className="text-3xl font-serif font-bold text-white mb-8">All Videos</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="w-full aspect-video">
<iframe
width="100%"
height="100%"
src="https://www.youtube.com/embed/vfKJjCgnmOU"
title="Jerry Higginson Video 1"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="rounded-lg shadow-lg"
></iframe>
</div>
<div className="w-full aspect-video">
<iframe
width="100%"
height="100%"
src="https://www.youtube.com/embed/RzWGdnx13hw"
title="Jerry Higginson Video 2"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="rounded-lg shadow-lg"
></iframe>
</div>
<div className="w-full aspect-video">
<iframe
width="100%"
height="100%"
src="https://www.youtube.com/embed/qkn8V8qf7Ow"
title="Jerry Higginson Video 3"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="rounded-lg shadow-lg"
></iframe>
</div>
</div>
</div>
</section>
{/* Listen to Jerry Section */}
<section
className="py-16 px-6 bg-cover bg-center relative"
style={{ backgroundImage: "url('/placeholder.svg?height=400&width=1200')" }}
>
<div className="absolute inset-0 bg-black bg-opacity-40"></div>
<div className="max-w-4xl mx-auto text-center relative z-10">
<div className="bg-white bg-opacity-90 rounded-lg p-8">
<h2 className="text-3xl font-serif font-bold text-gray-800 mb-4">
Listen to Jerry Singing with the Eagles
</h2>
<p className="text-gray-600 mb-6">
Jerry and his eagle friend have been flying and singing together for years.
</p>
<div className="w-full aspect-video mb-4">
<iframe
width="100%"
height="100%"
src="https://www.youtube.com/embed/DjPrfIlcWUk"
title="Jerry Singing with the Eagles"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
className="rounded"
></iframe>
</div>
</div>
</div>
</section>
{/* Art Collection Call-to-Action Section */}
<section className="py-16 px-6 bg-gray-50">
<div className="max-w-6xl mx-auto text-center">
<div className="border-t border-gray-200 pt-12">
<h2 className="text-3xl font-serif font-bold text-gray-800 mb-4">Buy Traditional Indigenous Artwork</h2>
<p className="text-gray-600 mb-8 text-lg">
Explore Jerry's beautiful collection of traditional Indigenous masks and artwork, each piece telling a
story of cultural heritage and artistic mastery.
</p>
<div className="w-full max-w-2xl mx-auto mb-8">
<Image
src="/images/indigenous-masks.jpeg"
alt="Traditional Indigenous masks with cedar bark fringe"
width={600}
height={400}
className="rounded-lg shadow-lg object-cover w-full"
/>
</div>
<Link href="/masks-art">
<Button className="bg-blue-800 hover:bg-blue-600 text-white px-8 py-3 text-lg font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-300">
Check out Jerry's Indigenous Mask & Art Collection
<span className="ml-2"></span>
</Button>
</Link>
</div>
</div>
</section>
{/* Footer with Jerry's Contact Information */}
<footer className="bg-blue-900 text-white py-12 px-6">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 text-center md:text-left">
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Contact Jerry</h3>
<div className="space-y-3">
<div className="flex flex-col md:flex-row md:items-center justify-center md:justify-start">
<span className="font-semibold mb-1 md:mb-0 md:mr-2">Email:</span>
<a href="mailto:alertbaytrumpeter@icloud.com" className="hover:text-blue-300 transition-colors">
alertbaytrumpeter@icloud.com
</a>
</div>
<div className="flex flex-col md:flex-row md:items-center justify-center md:justify-start">
<span className="font-semibold mb-1 md:mb-0 md:mr-2">Phone:</span>
<a href="tel:250-641-6204" className="hover:text-blue-300 transition-colors">
250 - 641- 6204
</a>
</div>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Location</h3>
<div className="text-blue-100 leading-relaxed">
<p>Hyde Creek Rd</p>
<p>British Columbia V0N 2R0</p>
<p>Canada</p>
</div>
</div>
<div className="space-y-4">
<h3 className="text-xl font-serif font-bold">Alert Bay Trumpeter</h3>
<p className="text-blue-100">Spreading smiles at sea since 1996</p>
<div className="flex flex-col sm:flex-row justify-center md:justify-start gap-4 sm:gap-6">
<Link href="/jerry-story" className="hover:text-blue-300 transition-colors text-center">
Jerry's Story
</Link>
<Link href="/masks-art" className="hover:text-blue-300 transition-colors text-center">
Art Collection
</Link>
<Link href="/contact" className="hover:text-blue-300 transition-colors text-center">
Contact Jerry
</Link>
</div>
</div>
</div>
</div>
</footer>
</div>
)
}

85
app/stripe-styles.css Normal file
View File

@ -0,0 +1,85 @@
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background: #242d60;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", "Ubuntu", sans-serif;
height: 100vh;
margin: 0;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
section {
background: #ffffff;
display: flex;
flex-direction: column;
width: 400px;
height: 112px;
border-radius: 6px;
justify-content: space-between;
margin: 10px;
}
.product {
display: flex;
}
.description {
display: flex;
flex-direction: column;
justify-content: center;
}
p {
font-style: normal;
font-weight: 500;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.154px;
color: #242d60;
height: 100%;
width: 100%;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
svg {
border-radius: 6px;
margin: 10px;
width: 54px;
height: 57px;
}
h3,
h5 {
font-style: normal;
font-weight: 500;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.154px;
color: #242d60;
margin: 0;
}
h5 {
opacity: 0.5;
}
a {
text-decoration: none;
color: white;
}
#checkout-and-portal-button {
height: 36px;
background: #556cd6;
color: white;
width: 100%;
font-size: 14px;
border: 0;
font-weight: 500;
cursor: pointer;
letter-spacing: 0.6;
border-radius: 0 0 6px 6px;
transition: all 0.2s ease;
box-shadow: 0px 4px 5.5px 0px rgba(0, 0, 0, 0.07);
}
#checkout-and-portal-button:hover {
opacity: 0.8;
}

3
app/success/loading.tsx Normal file
View File

@ -0,0 +1,3 @@
export default function Loading() {
return null
}

68
app/success/page.tsx Normal file
View File

@ -0,0 +1,68 @@
"use client"
import { useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { CheckCircle } from "lucide-react"
export default function SuccessPage() {
const searchParams = useSearchParams()
const sessionId = searchParams.get("session_id")
const [showPortalButton, setShowPortalButton] = useState(false)
useEffect(() => {
if (sessionId) {
setShowPortalButton(true)
}
}, [sessionId])
const handleManageBilling = async () => {
if (!sessionId) return
try {
const response = await fetch("/api/create-portal-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ session_id: sessionId }),
})
const { url } = await response.json()
if (url) {
window.location.href = url
}
} catch (error) {
console.error("Error creating portal session:", error)
}
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<Card className="max-w-md w-full">
<CardContent className="p-8 text-center">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-6" />
<h1 className="text-2xl font-bold text-gray-900 mb-4">Thank You for Your Support!</h1>
<p className="text-gray-600 mb-6">
Your donation to support Jerry Higginson, the Alert Bay Trumpeter, has been processed successfully. Jerry
appreciates your contribution to help him continue spreading smiles at sea!
</p>
<div className="space-y-4">
<Link href="/">
<Button className="w-full">Return to Home</Button>
</Link>
{showPortalButton && (
<Button variant="outline" className="w-full bg-transparent" onClick={handleManageBilling}>
Manage Billing Information
</Button>
)}
</div>
</CardContent>
</Card>
</div>
)
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

59
components/ui/button.tsx Normal file
View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

21
components/ui/input.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

14
next.config.mjs Normal file
View File

@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
}
export default nextConfig

58
package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "alert-bay-trumpeter",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"dev": "next dev",
"lint": "next lint",
"start": "next start"
},
"dependencies": {
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.1",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-collapsible": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-hover-card": "^1.1.2",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-menubar": "^1.1.2",
"@radix-ui/react-navigation-menu": "^1.2.1",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.1",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slider": "^1.2.1",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.3.1",
"lucide-react": "^0.454.0",
"next": "15.2.4",
"react": "^19",
"react-dom": "^19",
"stripe": "latest",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.17.0",
"eslint-config-next": "15.1.3",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"typescript": "^5"
}
}

4898
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

BIN
public/placeholder-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
public/placeholder-user.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/placeholder.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/placeholder.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

10
public/stripe-client.js Normal file
View File

@ -0,0 +1,10 @@
// In production, this should check CSRF, and not pass the session ID.
// The customer ID for the portal should be pulled from the
// authenticated user on the server.
document.addEventListener("DOMContentLoaded", async () => {
const searchParams = new URLSearchParams(window.location.search)
if (searchParams.has("session_id")) {
const session_id = searchParams.get("session_id")
document.getElementById("session-id").setAttribute("value", session_id)
}
})

125
styles/globals.css Normal file
View File

@ -0,0 +1,125 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"target": "ES6",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}