Compare commits

..

No commits in common. "main" and "fix/css-compilation-issues" have entirely different histories.

24 changed files with 63 additions and 16041 deletions

View File

@ -1,28 +0,0 @@
name: Mirror to Gitea
on:
push:
branches:
- main
- master
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Mirror to Gitea
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_USERNAME: ${{ secrets.GITEA_USERNAME }}
run: |
REPO_NAME=$(basename $GITHUB_REPOSITORY)
git remote add gitea https://$GITEA_USERNAME:$GITEA_TOKEN@gitea.jeffemmett.com/jeffemmett/$REPO_NAME.git || true
git push gitea --all --force
git push gitea --tags --force

View File

@ -1,66 +0,0 @@
# Deploying to Cloudflare Pages
This Next.js app is configured to run on Cloudflare Pages with full server-side functionality.
## Prerequisites
1. Install Wrangler CLI globally:
\`\`\`bash
npm install -g wrangler
\`\`\`
2. Login to Cloudflare:
\`\`\`bash
wrangler login
\`\`\`
## Local Development
Run the app locally with Cloudflare Workers simulation:
\`\`\`bash
npm run preview
\`\`\`
## Build for Cloudflare
Build the app for Cloudflare Pages:
\`\`\`bash
npm run pages:build
\`\`\`
This creates a `.vercel/output/static` directory with your built app.
## Deploy to Cloudflare Pages
### Option 1: Command Line Deployment
\`\`\`bash
npm run deploy
\`\`\`
### Option 2: Cloudflare Dashboard
1. Go to [Cloudflare Pages](https://dash.cloudflare.com/pages)
2. Create a new project
3. Connect your Git repository
4. Set build settings:
- **Build command**: `npm run pages:build`
- **Build output directory**: `.vercel/output/static`
- **Root directory**: `/`
## Environment Variables
Add these environment variables in Cloudflare Pages dashboard:
- `STRIPE_SECRET_KEY`
- `STRIPE_PUBLISHABLE_KEY`
- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
- `STRIPE_WEBHOOK_SECRET`
- `SENDGRID_API_KEY` - For sending notification emails (thank you emails after purchases)
## Important Notes
- API routes run as Cloudflare Workers
- Stripe webhooks will need to be updated to your Cloudflare Pages URL
- All server-side features (API routes, dynamic rendering) are fully supported
- The app uses Edge Runtime for optimal performance on Cloudflare's network

View File

@ -1,43 +0,0 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

View File

@ -1,24 +1,8 @@
import { type NextRequest, NextResponse } from "next/server" import { type NextRequest, NextResponse } from "next/server"
import Stripe from "stripe" import Stripe from "stripe"
export const runtime = "edge"
function getBaseUrl(request: NextRequest): string {
// Use environment variable if set, otherwise construct from headers
if (process.env.NEXT_PUBLIC_BASE_URL) {
return process.env.NEXT_PUBLIC_BASE_URL
}
const host = request.headers.get("host") || request.headers.get("x-forwarded-host")
const protocol = request.headers.get("x-forwarded-proto") || "https"
if (host) {
return `${protocol}://${host}`
}
return "https://alertbaytrumpeter.com"
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const baseUrl = getBaseUrl(request)
const stripeSecretKey = process.env.STRIPE_SECRET_KEY const stripeSecretKey = process.env.STRIPE_SECRET_KEY
if (!stripeSecretKey) { if (!stripeSecretKey) {
@ -74,8 +58,8 @@ export async function POST(request: NextRequest) {
quantity: 1, quantity: 1,
}, },
], ],
success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`, success_url: `${request.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/cancel`, cancel_url: `${request.nextUrl.origin}/cancel`,
}) })
console.log("[v0] Subscription checkout session created successfully:", session.id) console.log("[v0] Subscription checkout session created successfully:", session.id)
@ -103,8 +87,8 @@ export async function POST(request: NextRequest) {
quantity: 1, quantity: 1,
}, },
], ],
success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`, success_url: `${request.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/cancel`, cancel_url: `${request.nextUrl.origin}/cancel`,
}) })
console.log("[v0] Monthly subscription checkout session created successfully:", session.id) console.log("[v0] Monthly subscription checkout session created successfully:", session.id)
@ -154,8 +138,8 @@ export async function POST(request: NextRequest) {
quantity: 1, quantity: 1,
}, },
], ],
success_url: `${baseUrl}/success?session_id={CHECKOUT_SESSION_ID}`, success_url: `${request.nextUrl.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/cancel`, cancel_url: `${request.nextUrl.origin}/cancel`,
}) })
console.log("[v0] Checkout session created successfully:", session.id) console.log("[v0] Checkout session created successfully:", session.id)

View File

@ -1,33 +1,12 @@
import { type NextRequest, NextResponse } from "next/server" import { type NextRequest, NextResponse } from "next/server"
import Stripe from "stripe" import Stripe from "stripe"
export const runtime = "edge" const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
function getBaseUrl(request: NextRequest): string { })
if (process.env.NEXT_PUBLIC_BASE_URL) {
return process.env.NEXT_PUBLIC_BASE_URL
}
const host = request.headers.get("host") || request.headers.get("x-forwarded-host")
const protocol = request.headers.get("x-forwarded-proto") || "https"
if (host) {
return `${protocol}://${host}`
}
return "https://alertbaytrumpeter.com"
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const baseUrl = getBaseUrl(request)
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim()
if (!stripeSecretKey) {
return NextResponse.json({ error: "Stripe configuration missing" }, { status: 500 })
}
const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2024-06-20",
})
const { session_id } = await request.json() const { session_id } = await request.json()
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id) const checkoutSession = await stripe.checkout.sessions.retrieve(session_id)
@ -38,7 +17,7 @@ export async function POST(request: NextRequest) {
const portalSession = await stripe.billingPortal.sessions.create({ const portalSession = await stripe.billingPortal.sessions.create({
customer: checkoutSession.customer as string, customer: checkoutSession.customer as string,
return_url: baseUrl, return_url: `${request.nextUrl.origin}`,
}) })
return NextResponse.json({ url: portalSession.url }) return NextResponse.json({ url: portalSession.url })

View File

@ -1,8 +1,6 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import Stripe from "stripe" import Stripe from "stripe"
export const runtime = "edge"
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { priceId } = await request.json() const { priceId } = await request.json()

View File

@ -1,8 +1,6 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import Stripe from "stripe" import Stripe from "stripe"
export const runtime = "edge"
export async function GET() { export async function GET() {
try { try {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim()

View File

@ -1,7 +1,5 @@
import { type NextRequest, NextResponse } from "next/server" import { type NextRequest, NextResponse } from "next/server"
export const runtime = "edge"
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { name, email, subject, message } = await request.json() const { name, email, subject, message } = await request.json()

View File

@ -1,8 +1,6 @@
import { type NextRequest, NextResponse } from "next/server" import { type NextRequest, NextResponse } from "next/server"
import Stripe from "stripe" import Stripe from "stripe"
export const runtime = "edge"
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim() const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim()

View File

@ -1,101 +1,14 @@
import { type NextRequest, NextResponse } from "next/server" import { type NextRequest, NextResponse } from "next/server"
import Stripe from "stripe" import Stripe from "stripe"
export const runtime = "edge" const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-06-20",
})
async function sendThankYouEmail( const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
customerEmail: string,
customerName: string | null,
amount: number,
currency: string,
productName: string
) {
const sendgridApiKey = process.env.SENDGRID_API_KEY?.trim()
if (!sendgridApiKey) {
console.log("⚠️ SENDGRID_API_KEY not configured, skipping email")
return
}
const formattedAmount = new Intl.NumberFormat("en-CA", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100)
const name = customerName || "Valued Supporter"
try {
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
method: "POST",
headers: {
"Authorization": `Bearer ${sendgridApiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
personalizations: [
{
to: [{ email: customerEmail, name: name }],
},
],
from: {
email: "noreply@alertbaytrumpeter.com",
name: "Alert Bay Trumpeter",
},
subject: "Thank You for Supporting the Alert Bay Trumpeter!",
content: [
{
type: "text/html",
value: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #1e40af;">Thank You, ${name}!</h1>
<p>Your generous support means the world to Jerry Higginson, the Alert Bay Trumpeter.</p>
<div style="background-color: #f0f9ff; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h2 style="color: #1e40af; margin-top: 0;">Subscription Details</h2>
<p><strong>Amount:</strong> ${formattedAmount}/month</p>
<p><strong>Plan:</strong> ${productName}</p>
</div>
<p>Your monthly contribution helps Jerry continue spreading smiles at sea by serenading cruise ship passengers with his trumpet.</p>
<p>With over 1,000 nautical serenades since 1996, your support keeps the music playing!</p>
<p style="margin-top: 30px;">
With gratitude,<br>
<strong>Jerry Higginson</strong><br>
The Alert Bay Trumpeter
</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 30px 0;">
<p style="color: #6b7280; font-size: 12px;">
If you have any questions, contact us at alertbaytrumpeter@icloud.com
</p>
</div>
`,
},
],
}),
})
if (response.ok || response.status === 202) {
console.log(`✅ Thank you email sent to ${customerEmail}`)
} else {
const error = await response.text()
console.error(`❌ Failed to send email: ${error}`)
}
} catch (error) {
console.error("❌ Error sending email:", error)
}
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const stripeSecretKey = process.env.STRIPE_SECRET_KEY?.trim()
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET?.trim()
if (!stripeSecretKey || !webhookSecret) {
return NextResponse.json({ error: "Stripe configuration missing" }, { status: 500 })
}
const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2024-06-20",
})
const body = await request.text() const body = await request.text()
const signature = request.headers.get("stripe-signature")! const signature = request.headers.get("stripe-signature")!
@ -113,45 +26,12 @@ export async function POST(request: NextRequest) {
case "checkout.session.completed": case "checkout.session.completed":
const session = event.data.object as Stripe.Checkout.Session const session = event.data.object as Stripe.Checkout.Session
console.log(`💰 Payment successful for session: ${session.id}`) console.log(`💰 Payment successful for session: ${session.id}`)
// Handle successful payment here
// Send thank you email for subscriptions
if (session.mode === "subscription" && session.customer_details?.email) {
// Get subscription details
const subscriptionId = session.subscription as string
if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
const item = subscription.items.data[0]
const amount = item.price.unit_amount || 0
const currency = item.price.currency
const productId = item.price.product as string
const product = await stripe.products.retrieve(productId)
await sendThankYouEmail(
session.customer_details.email,
session.customer_details.name,
amount,
currency,
product.name
)
}
}
break break
case "payment_intent.succeeded":
case "customer.subscription.created": const paymentIntent = event.data.object as Stripe.PaymentIntent
const newSubscription = event.data.object as Stripe.Subscription console.log(`💰 Payment succeeded: ${paymentIntent.id}`)
console.log(`🎉 New subscription created: ${newSubscription.id}`)
break break
case "invoice.paid":
const invoice = event.data.object as Stripe.Invoice
console.log(`💵 Invoice paid: ${invoice.id}`)
break
case "customer.subscription.deleted":
const cancelledSubscription = event.data.object as Stripe.Subscription
console.log(`😢 Subscription cancelled: ${cancelledSubscription.id}`)
break
default: default:
console.log(`Unhandled event type ${event.type}`) console.log(`Unhandled event type ${event.type}`)
} }

View File

@ -1,5 +1,4 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
:root { :root {
--font-inter: var(--font-inter); --font-inter: var(--font-inter);

View File

@ -21,33 +21,6 @@ export const metadata: Metadata = {
description: description:
"The official website of Jerry Higginson, the Alert Bay Trumpeter, entertaining cruise ship passengers since 1996", "The official website of Jerry Higginson, the Alert Bay Trumpeter, entertaining cruise ship passengers since 1996",
generator: "v0.app", generator: "v0.app",
icons: {
icon: "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎺</text></svg>",
},
openGraph: {
title: "Alert Bay Trumpeter - Jerry Higginson",
description:
"The official website of Jerry Higginson, the Alert Bay Trumpeter, entertaining cruise ship passengers since 1996",
url: "https://alertbaytrumpeter.com",
siteName: "Alert Bay Trumpeter",
images: [
{
url: "/og-image.jpg",
width: 1200,
height: 630,
alt: "Jerry Higginson - Alert Bay Trumpeter",
},
],
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "Alert Bay Trumpeter - Jerry Higginson",
description:
"The official website of Jerry Higginson, the Alert Bay Trumpeter, entertaining cruise ship passengers since 1996",
images: ["/og-image.jpg"],
},
} }
export default function RootLayout({ export default function RootLayout({

View File

@ -50,14 +50,21 @@ export default function HomePage() {
try { try {
console.log("[v0] Starting donation process for product:", product.name) console.log("[v0] Starting donation process for product:", product.name)
// Always use subscription mode for products fetched from Stripe let requestBody: any
const requestBody = {
productId: product.id, if (product.lookup_key) {
productName: product.name, // Use lookup_key for one-time donations (legacy sponsor tiers)
price: product.price, requestBody = { lookup_key: product.lookup_key }
currency: product.currency, } else {
description: product.description, // Use product info for subscription checkouts
mode: "subscription", 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", { const response = await fetch("/api/create-checkout-session", {

View File

@ -1,15 +0,0 @@
project_name: "Alert Bay Trumpeter"
default_status: "To Do"
statuses: ["To Do", "In Progress", "Done"]
labels: []
milestones: []
date_format: yyyy-mm-dd
max_column_width: 20
default_editor: "vim"
auto_open_browser: true
default_port: 6420
remote_operations: true
auto_commit: false
bypass_git_hooks: false
check_active_branches: true
active_branch_days: 30

View File

@ -1,24 +0,0 @@
---
id: task-1
title: Finalize SendGrid API connection for alertbaytrumpeter.com
status: To Do
assignee: []
created_date: '2025-12-06 14:37'
labels: []
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Complete the SendGrid integration for the Alert Bay Trumpeter website notification emails
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Create SendGrid account and generate API key
- [ ] #2 Verify sender domain (alertbaytrumpeter.com) in SendGrid
- [ ] #3 Add SENDGRID_API_KEY to Cloudflare Pages environment variables
- [ ] #4 Test email delivery with a real subscription
<!-- AC:END -->

View File

@ -9,13 +9,14 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', '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: 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', '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: secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80', 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
@ -25,8 +26,6 @@ const buttonVariants = cva(
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', 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', lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9', icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', '@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, className,
)} )}
{...props} {...props}

View File

@ -1,6 +1,5 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'standalone',
eslint: { eslint: {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
@ -12,4 +11,4 @@ const nextConfig = {
}, },
} }
export default nextConfig export default nextConfig

12716
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "next build", "build": "next build",
"deploy": "npm run pages:build && wrangler pages deploy",
"dev": "next dev", "dev": "next dev",
"lint": "next lint", "lint": "next lint",
"pages:build": "npx @cloudflare/next-on-pages",
"preview": "npm run pages:build && wrangler pages dev",
"start": "next start" "start": "next start"
}, },
"dependencies": { "dependencies": {
@ -47,21 +44,17 @@
"recharts": "latest", "recharts": "latest",
"stripe": "latest", "stripe": "latest",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7"
"tw-animate-css": "latest"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/next-on-pages": "^1.13.5",
"@tailwindcss/postcss": "^4.1.9", "@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22", "@types/node": "^22",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "15.2.4", "eslint-config-next": "15.1.3",
"postcss": "^8.5", "postcss": "^8.5",
"tailwindcss": "^4.1.9", "tailwindcss": "^4.1.9",
"typescript": "^5", "typescript": "^5"
"vercel": "^37.14.0",
"wrangler": "^3.94.0"
} }
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

View File

@ -1,5 +1,4 @@
@import 'tailwindcss'; @import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@ -75,8 +74,8 @@
} }
@theme inline { @theme inline {
--font-sans: var(--font-geist-sans); --font-sans: var(--font-inter);
--font-mono: var(--font-geist-mono); --font-serif: var(--font-playfair);
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);

View File

@ -1,7 +0,0 @@
name = "alert-bay-trumpeter"
compatibility_date = "2024-12-01"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".vercel/output/static"
[env.production]
vars = { NODE_ENV = "production" }