Compare commits

..

No commits in common. "main" and "vite-version-reference" have entirely different histories.

144 changed files with 1763 additions and 23172 deletions

View File

@ -1,15 +1,19 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(timeout:*)", "Bash(git pull:*)",
"Bash(timeout /t 2)", "mcp__serena__list_dir",
"Bash(if exist .nextdevlock del /F .nextdevlock)", "Bash(cat:*)",
"Bash(if exist .nextdev rd /S /Q .nextdev)", "mcp__zen__planner",
"mcp__serena__initial_instructions", "Bash(git add:*)",
"mcp__serena__get_current_config", "Bash(git commit:*)",
"mcp__playwright__browser_fill_form", "Bash(git push:*)",
"WebSearch", "mcp__zen__debug",
"mcp__serena__check_onboarding_performed" "mcp__zen__consensus",
"mcp__serena__find_symbol",
"mcp__serena__search_for_pattern",
"mcp__serena__activate_project",
"mcp__serena__get_symbols_overview"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@ -31,12 +31,6 @@ WREN_DRY_RUN=true
# === Database Configuration === # === Database Configuration ===
DATABASE_PATH=/app/data/orders.db DATABASE_PATH=/app/data/orders.db
# === NocoDB Configuration ===
NOCODB_BASE_URL=https://your-nocodb-instance.com
NOCODB_BASE_ID=your_base_id_here
NOCODB_API_KEY=your_nocodb_api_key_here
NOCODB_ORDERS_TABLE_ID=your_orders_table_id_here
# === Email Configuration === # === Email Configuration ===
SMTP_HOST=mail.puffinoffset.com SMTP_HOST=mail.puffinoffset.com
SMTP_PORT=587 SMTP_PORT=587
@ -47,11 +41,6 @@ SMTP_FROM_NAME=Puffin Offset
SMTP_FROM_EMAIL=noreply@puffinoffset.com SMTP_FROM_EMAIL=noreply@puffinoffset.com
ADMIN_EMAIL=matt@puffinoffset.com ADMIN_EMAIL=matt@puffinoffset.com
# === Admin Portal Authentication ===
ADMIN_USERNAME=your_admin_username_here
ADMIN_PASSWORD=your_admin_password_here
JWT_SECRET=your_jwt_secret_key_here
# ======================================== # ========================================
# NOTES # NOTES
# ======================================== # ========================================

3
.gitignore vendored
View File

@ -29,6 +29,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/.next/
/.playwright-mcp/
/nul

View File

@ -1,417 +0,0 @@
# Puffin Offset API Documentation
## Table of Contents
- [QR Code Generation API](#qr-code-generation-api)
- [Endpoint](#endpoint)
- [Request Format](#request-format)
- [Calculation Types](#calculation-types)
- [Response Format](#response-format)
- [Format Comparison (PNG vs SVG)](#format-comparison-png-vs-svg)
- [Usage Examples](#usage-examples)
- [Checkout API](#checkout-api)
- [Best Practices](#best-practices)
- [Error Handling](#error-handling)
## QR Code Generation API
### Endpoint
```
POST /api/qr-code/generate
```
Generate QR codes that encode carbon calculation parameters. The QR code directs users to the Puffin Offset calculator with pre-filled calculation data.
### Request Format
The API accepts three types of calculations:
#### 1. Fuel-Based Calculation
Calculate carbon offset based on fuel consumption:
```json
{
"calculationType": "fuel",
"fuelAmount": 1000,
"fuelUnit": "liters",
"vessel": {
"imo": "1234567",
"name": "Sample Yacht",
"type": "Motor Yacht",
"enginePower": 2250
},
"timestamp": "2025-03-15T10:00:00Z",
"source": "marina-api"
}
```
**Fields:**
- `calculationType`: Must be `"fuel"`
- `fuelAmount`: Number of fuel units consumed
- `fuelUnit`: Either `"liters"` or `"gallons"`
- `vessel`: Optional vessel metadata (for informational purposes only)
- `timestamp`: Optional ISO timestamp
- `source`: Optional identifier (e.g., "marina-api", "broker-portal")
#### 2. Distance-Based Calculation
Calculate carbon offset based on trip distance and vessel characteristics:
```json
{
"calculationType": "distance",
"distance": 150,
"speed": 12,
"fuelRate": 85,
"vessel": {
"imo": "1234567",
"name": "Sample Yacht",
"type": "Motor Yacht",
"enginePower": 2250
},
"timestamp": "2025-03-15T10:00:00Z",
"source": "marina-api"
}
```
**Fields:**
- `calculationType`: Must be `"distance"`
- `distance`: Distance in nautical miles
- `speed`: Average speed in knots
- `fuelRate`: Fuel consumption rate in liters per hour
- `vessel`: Optional vessel metadata
- `timestamp`: Optional ISO timestamp
- `source`: Optional identifier
#### 3. Custom Amount
Direct monetary amount for carbon offsetting:
```json
{
"calculationType": "custom",
"customAmount": 250.00,
"vessel": {
"name": "Corporate Event",
"type": "Conference"
},
"timestamp": "2025-03-15T10:00:00Z",
"source": "event-organizer"
}
```
**Fields:**
- `calculationType`: Must be `"custom"`
- `customAmount`: Dollar amount (USD) for direct offset purchase
- `vessel`: Optional context information
- `timestamp`: Optional ISO timestamp
- `source`: Optional identifier
### Response Format
The API returns both PNG and SVG formats of the QR code:
```json
{
"success": true,
"data": {
"qrCodeDataURL": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg...",
"qrCodeSVG": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 256 256\">...</svg>",
"url": "https://puffinoffset.com/calculator?qr=eyJjYWxjdWxhdGlvblR5cGUiOi...",
"expiresAt": "2025-04-14T10:00:00.000Z"
}
}
```
**Response Fields:**
- `qrCodeDataURL`: Base64-encoded PNG image as data URL (ready for `<img>` src)
- `qrCodeSVG`: SVG markup string (ready for direct HTML injection)
- `url`: The full URL that the QR code points to
- `expiresAt`: ISO timestamp when the QR code expires (30 days from generation)
### Format Comparison (PNG vs SVG)
| Feature | PNG (qrCodeDataURL) | SVG (qrCodeSVG) |
|---------|---------------------|-----------------|
| **Format** | Base64-encoded PNG as data URL | SVG markup string |
| **Use Case** | Email, printing, basic displays | Web, scaling, professional prints |
| **File Size** | Larger (~5-10 KB) | Smaller (~2-4 KB) |
| **Scalability** | Pixelated when enlarged | Perfect at any size |
| **Browser Support** | Universal | Universal (all modern browsers) |
| **Best For** | Quick implementation, emails | Websites, responsive design, high-DPI displays |
| **Embedding** | `<img src="{qrCodeDataURL}">` | Direct HTML injection or `<img src="data:image/svg+xml,{encoded}">` |
**When to Use PNG:**
- Email campaigns (better compatibility)
- Print materials with fixed sizes
- Quick prototyping
- When exact pixel dimensions are known
**When to Use SVG:**
- Responsive web design
- High-resolution displays (Retina, 4K)
- Professional print materials (scales to any size)
- When minimizing file size is important
- Dynamic styling with CSS
### Usage Examples
#### cURL
```bash
curl -X POST https://puffinoffset.com/api/qr-code/generate \
-H "Content-Type: application/json" \
-d '{
"calculationType": "distance",
"distance": 150,
"speed": 12,
"fuelRate": 85,
"vessel": {
"imo": "1234567",
"name": "Sample Yacht"
}
}'
```
#### JavaScript (Fetch API)
```javascript
const response = await fetch('https://puffinoffset.com/api/qr-code/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
calculationType: 'fuel',
fuelAmount: 1000,
fuelUnit: 'liters',
vessel: {
name: 'Sample Yacht',
imo: '1234567'
}
})
});
const { data } = await response.json();
console.log('QR Code URL:', data.url);
// Use PNG format
document.getElementById('qr-png').src = data.qrCodeDataURL;
// Use SVG format
document.getElementById('qr-svg-container').innerHTML = data.qrCodeSVG;
```
#### Python
```python
import requests
response = requests.post(
'https://puffinoffset.com/api/qr-code/generate',
json={
'calculationType': 'custom',
'customAmount': 250.00,
'source': 'python-client'
}
)
data = response.json()['data']
print(f"QR Code URL: {data['url']}")
print(f"Expires at: {data['expiresAt']}")
# Save PNG to file
import base64
png_data = data['qrCodeDataURL'].split(',')[1]
with open('qr-code.png', 'wb') as f:
f.write(base64.b64decode(png_data))
# Save SVG to file
with open('qr-code.svg', 'w') as f:
f.write(data['qrCodeSVG'])
```
#### HTML Embedding
**PNG Format:**
```html
<!-- Direct data URL in img tag -->
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUg..."
alt="Carbon Offset QR Code"
style="width: 300px; height: 300px;">
```
**SVG Format:**
```html
<!-- Direct SVG injection (recommended for web) -->
<div id="qr-container">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- SVG content from API -->
</svg>
</div>
<!-- Or as data URL -->
<img src="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'>...</svg>"
alt="Carbon Offset QR Code"
style="width: 100%; max-width: 400px;">
```
**Dynamic Sizing with SVG:**
```css
/* SVG automatically scales to container */
#qr-container {
width: 100%;
max-width: 500px;
margin: 0 auto;
}
#qr-container svg {
width: 100%;
height: auto;
}
```
## Checkout API
### Create Checkout Session
```
POST /api/checkout/create-session
```
Create a Stripe checkout session for carbon offset purchase.
**Request Body:**
```json
{
"tons": 2.5,
"portfolioId": 123,
"pricePerTon": 20.00,
"customerEmail": "customer@example.com"
}
```
**Response:**
```json
{
"sessionId": "cs_test_...",
"url": "https://checkout.stripe.com/...",
"orderId": "order_abc123"
}
```
**Note:** Vessel information is NOT included in checkout sessions or stored in orders. Vessel metadata in QR codes is for informational purposes only.
### Get Order Details
```
GET /api/checkout/session/{sessionId}
```
Retrieve order details by Stripe session ID.
**Response:**
```json
{
"order": {
"id": "order_abc123",
"tons": 2.5,
"portfolioId": 123,
"baseAmount": 5000,
"processingFee": 180,
"totalAmount": 5180,
"currency": "USD",
"status": "paid",
"wrenOrderId": "wren_xyz789",
"stripeSessionId": "cs_test_...",
"createdAt": "2025-03-15T10:00:00.000Z"
},
"session": {
"paymentStatus": "paid",
"customerEmail": "customer@example.com"
}
}
```
## Best Practices
### QR Code Generation
1. **Choose the Right Format:**
- Use **PNG** for emails, fixed-size prints, and quick prototypes
- Use **SVG** for websites, responsive designs, and high-quality prints
2. **Include Metadata:**
- Always include `source` field to track where QR codes originate
- Include `timestamp` for auditing and expiration tracking
- Use descriptive `vessel` names for context
3. **Handle Expiration:**
- QR codes expire after 30 days
- Check `expiresAt` timestamp in response
- Regenerate QR codes if needed for long-term use
4. **Error Handling:**
- Validate input data before sending to API
- Handle network failures gracefully
- Display user-friendly error messages
### Integration Recommendations
1. **Marinas & Brokers:**
- Generate QR codes after trip logging
- Print QR codes on receipts or invoices
- Include vessel details for tracking
2. **Event Organizers:**
- Use `custom` calculation type for flat-rate offsets
- Generate bulk QR codes for attendee packets
- Track source with `source` field
3. **Mobile Apps:**
- Use SVG format for responsive display
- Cache QR codes locally to reduce API calls
- Implement QR code scanning for peer-to-peer sharing
## Error Handling
### Error Response Format
```json
{
"success": false,
"error": "Invalid calculation type"
}
```
### Common Errors
| Error | Cause | Solution |
|-------|-------|----------|
| "Invalid calculation type" | `calculationType` not "fuel", "distance", or "custom" | Use valid calculation type |
| "Missing required fields" | Required fields for calculation type not provided | Check calculation type requirements |
| "Invalid fuel unit" | `fuelUnit` not "liters" or "gallons" | Use valid fuel unit |
| "Invalid amount" | Negative or zero values | Provide positive values |
| 500 Server Error | Server-side issue | Retry request, contact support if persistent |
### HTTP Status Codes
- **200 OK**: QR code generated successfully
- **400 Bad Request**: Invalid request data (check error message)
- **500 Internal Server Error**: Server-side error (retry or contact support)
## Future Enhancements
The following features are planned for future releases:
1. **Authentication:** API key authentication for rate limiting and analytics
2. **Batch Generation:** Generate multiple QR codes in a single request
3. **Custom Branding:** Add logos or custom colors to QR codes
4. **Analytics Dashboard:** Track QR code scans and conversion rates
5. **Webhook Support:** Receive notifications when QR codes are scanned or orders completed
## Changelog
### Version 1.0 (Current)
- Initial release
- Support for fuel-based, distance-based, and custom amount calculations
- PNG and SVG format outputs
- 30-day expiration
- No authentication required
---
**Support:** For questions or issues, contact [support@puffinoffset.com](mailto:support@puffinoffset.com)
**API Base URL:** `https://puffinoffset.com/api`

View File

@ -7,31 +7,27 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
# Copy the rest of the app # Copy the rest of the app and build it
COPY . . COPY . .
# Build Next.js app (standalone mode)
# All environment variables are runtime-configurable via .env or docker-compose
RUN npm run build RUN npm run build
# Production Stage - Next.js standalone server # Production Stage - Simple HTTP server
FROM node:20-alpine FROM node:20-alpine
WORKDIR /app WORKDIR /app
# Copy standalone server files from build stage # Install serve package globally for serving static files
COPY --from=build /app/.next/standalone ./ RUN npm install -g serve
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public # Copy the built app from the build stage
COPY --from=build /app/dist ./dist
# Copy the env.sh script for runtime configuration
COPY env.sh /app/env.sh
RUN chmod +x /app/env.sh
# Expose port 3000 # Expose port 3000
EXPOSE 3000 EXPOSE 3000
# Set environment to production # Use env.sh to generate runtime config, then start serve
ENV NODE_ENV=production CMD ["/app/env.sh", "serve", "-s", "dist", "-l", "3000"]
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Start Next.js server
# Runtime environment variables (NEXT_PUBLIC_*) can be passed via docker-compose or -e flags
CMD ["node", "server.js"]

View File

@ -1,85 +0,0 @@
# Environment Variables Migration Guide
## Vite → Next.js Environment Variable Changes
When migrating from Vite to Next.js, all environment variables need to be renamed:
### Required Changes
| Old (Vite) | New (Next.js) | Purpose |
|------------|---------------|---------|
| `VITE_WREN_API_TOKEN` | `NEXT_PUBLIC_WREN_API_TOKEN` | Wren Climate API authentication token |
| `VITE_API_BASE_URL` | `NEXT_PUBLIC_API_BASE_URL` | Backend API base URL |
| `VITE_STRIPE_PUBLISHABLE_KEY` | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Stripe payment gateway public key |
| `VITE_FORMSPREE_CONTACT_ID` | `NEXT_PUBLIC_FORMSPREE_CONTACT_ID` | Formspree contact form ID (if still used) |
| `VITE_FORMSPREE_OFFSET_ID` | `NEXT_PUBLIC_FORMSPREE_OFFSET_ID` | Formspree offset form ID (if still used) |
### Updated .env File
Your `.env` file should now look like this:
```env
# Wren Climate API
NEXT_PUBLIC_WREN_API_TOKEN=35c025d9-5dbb-404b-85aa-19b09da0578d
# Backend API
NEXT_PUBLIC_API_BASE_URL=http://localhost:3001
# Stripe (add when needed)
# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# Formspree (if still using)
# NEXT_PUBLIC_FORMSPREE_CONTACT_ID=xkgovnby
# NEXT_PUBLIC_FORMSPREE_OFFSET_ID=xvgzbory
```
### Docker Environment Variables
In your Docker deployment (via `docker-compose.yml` or environment injection), update:
```yaml
environment:
- NEXT_PUBLIC_WREN_API_TOKEN=${WREN_API_TOKEN}
- NEXT_PUBLIC_API_BASE_URL=${API_BASE_URL}
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY}
```
### Code Changes Made
The following file has been updated to use Next.js environment variables:
- **`src/utils/config.ts`**: Changed from `import.meta.env.VITE_*` to `process.env.NEXT_PUBLIC_*`
### Important Notes
1. **NEXT_PUBLIC_ prefix is required** for client-side environment variables in Next.js
2. The `next.config.mjs` file maps these variables for compatibility
3. Server-side only variables (like API secrets) should NOT have the `NEXT_PUBLIC_` prefix
4. The app will work without these variables but will fall back to default/demo modes
### Verification
To verify your environment variables are loaded correctly:
```bash
# Development
npm run dev
# Check console for:
# "Config: {wrenApiKey: '[REDACTED]', isProduction: false}"
# If you see "Missing required environment variable", update your .env file
```
### Production Deployment
For production deployments:
1. Update your `.env` file with `NEXT_PUBLIC_*` variables
2. If using Docker, update your `docker-compose.yml` or environment scripts
3. If using Gitea Actions, update CI/CD environment variables
4. Rebuild the application: `npm run build`
### Backward Compatibility
The `next.config.mjs` file includes fallback logic to support both naming conventions during the transition period, but you should update to the new names as soon as possible.

View File

@ -1,267 +0,0 @@
# Next.js Migration Complete ✅
## Migration Summary
Successfully migrated Puffin Offset from **Vite 6 + React** to **Next.js 16 App Router** (January 2025).
### What Was Migrated
#### Phase 1-2: Foundation & Configuration
- ✅ Installed Next.js 16.0.1 with Turbopack
- ✅ Created `next.config.mjs` with environment variable mapping
- ✅ Updated `tsconfig.json` for Next.js
- ✅ Created root layout with metadata
- ✅ Set up `app/globals.css`
- ✅ Migrated Header and Footer components
#### Phase 3: Core Pages
- ✅ **Homepage** (`app/page.tsx`) - Static landing page
- ✅ **About** (`app/about/page.tsx`) - Static about page with metadata
- ✅ **How It Works** (`app/how-it-works/page.tsx`) - Static process explanation
- ✅ **Contact** (`app/contact/page.tsx`) - Form with SMTP integration
#### Phase 4: Calculator & Special Routes
- ✅ **Calculator** (`app/calculator/page.tsx`) - Interactive carbon calculator
- ✅ **Mobile App** (`app/mobile-app/page.tsx`) - Custom layout without header/footer
- ✅ **Checkout Success** (`app/checkout/success/page.tsx`) - Stripe success handler
- ✅ **Checkout Cancel** (`app/checkout/cancel/page.tsx`) - Stripe cancel handler
#### Phase 5: SEO Enhancement
- ✅ Added comprehensive metadata to all pages
- ✅ Created `app/sitemap.ts` for search engines
- ✅ Created `app/robots.ts` for crawler rules
- ✅ Extracted client components for proper metadata exports
#### Phase 6: Docker & Deployment
- ✅ Updated `Dockerfile` for Next.js standalone mode
- ✅ Updated `docker-compose.yml` with NEXT_PUBLIC_ environment variables
- ✅ Backward compatibility for VITE_ variables during transition
#### Phase 7: Testing & Verification
- ✅ All routes loading successfully
- ✅ Environment variable migration documented
- ✅ Dev server running with Turbopack
- ✅ Calculator functionality verified
## Architecture Changes
### Before (Vite)
```
- Client-side only rendering (SPA)
- Vite dev server
- Static build output (dist/)
- Environment: import.meta.env.VITE_*
```
### After (Next.js 16)
```
- Server-side and client-side rendering (App Router)
- Next.js with Turbopack
- Standalone server output (.next/)
- Environment: process.env.NEXT_PUBLIC_*
```
## Key Technical Decisions
### 1. Client Component Strategy
**Decision**: Extract client logic to separate component files
**Reason**: Allows page files to export metadata (server components only)
Example:
```
app/about/page.tsx → Server component with metadata
components/AboutClient.tsx → Client component with interactivity
```
### 2. Environment Variables
**Old**: `import.meta.env.VITE_WREN_API_TOKEN`
**New**: `process.env.NEXT_PUBLIC_WREN_API_TOKEN`
**Fallback Strategy**: `next.config.mjs` includes backward compatibility during transition
### 3. Docker Deployment
**Old**: Static files served by `serve` package
**New**: Next.js standalone server with `node server.js`
## Files Created/Modified
### New Files
```
app/
├── layout.tsx # Root layout with metadata
├── page.tsx # Homepage
├── globals.css # Global styles
├── sitemap.ts # Dynamic sitemap
├── robots.ts # Crawler rules
├── about/page.tsx # About page with metadata
├── how-it-works/page.tsx # How It Works with metadata
├── contact/page.tsx # Contact with metadata
├── calculator/page.tsx # Calculator with metadata
├── mobile-app/
│ ├── page.tsx # Mobile calculator
│ └── layout.tsx # Custom layout (no header/footer)
└── checkout/
├── success/page.tsx # Stripe success
└── cancel/page.tsx # Stripe cancel
components/
├── Header.tsx # Navigation component
├── Footer.tsx # Footer component
├── AboutClient.tsx # About page client logic
├── HowItWorksClient.tsx # How It Works client logic
├── ContactClient.tsx # Contact form client logic
└── CalculatorClient.tsx # Calculator client logic
next.config.mjs # Next.js configuration
ENV_MIGRATION.md # Environment variable guide
NEXTJS_MIGRATION_COMPLETE.md # This file
```
### Modified Files
```
package.json # Updated dependencies and scripts
tsconfig.json # Next.js TypeScript config
Dockerfile # Next.js standalone build
docker-compose.yml # Updated environment variables
src/utils/config.ts # Environment variable handling
```
### Renamed/Archived
```
src/pages/ → src/old-pages/ # Old Vite page components (kept for reference)
```
## Environment Variable Migration
See **[ENV_MIGRATION.md](./ENV_MIGRATION.md)** for complete guide.
### Quick Reference
| Old (Vite) | New (Next.js) |
|------------|---------------|
| `VITE_WREN_API_TOKEN` | `NEXT_PUBLIC_WREN_API_TOKEN` |
| `VITE_API_BASE_URL` | `NEXT_PUBLIC_API_BASE_URL` |
| `VITE_STRIPE_PUBLISHABLE_KEY` | `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` |
## Benefits Achieved
### 1. SEO Improvement
- ✅ Server-side rendering for marketing pages
- ✅ Proper metadata and Open Graph tags
- ✅ Dynamic sitemap generation
- ✅ Search engine friendly URLs
### 2. Performance
- ✅ Faster initial page loads (SSR)
- ✅ Turbopack for instant dev server updates
- ✅ Automatic code splitting
- ✅ Image optimization ready
### 3. Developer Experience
- ✅ File-based routing
- ✅ Built-in API routes capability
- ✅ TypeScript integration
- ✅ Modern React 18 features
### 4. Maintainability
- ✅ Clearer separation of client/server code
- ✅ Better component organization
- ✅ Standalone Docker deployment
## Next Steps (Optional Future Enhancements)
### Short Term
- [ ] Add `next/image` for S3 images (already configured in next.config.mjs)
- [ ] Convert remaining inline styles to Tailwind utilities
- [ ] Add loading states with Next.js `loading.tsx` files
### Medium Term
- [ ] Implement API routes for backend proxy (replace direct API calls)
- [ ] Add middleware for request logging
- [ ] Set up incremental static regeneration (ISR) for dynamic content
### Long Term
- [ ] Migrate to App Router streaming (React Suspense)
- [ ] Add Edge runtime for global deployment
- [ ] Implement advanced caching strategies
## Testing Checklist
### Functionality Tests
- ✅ Homepage loads with animations
- ✅ About page displays correctly
- ✅ How It Works page shows all steps
- ✅ Contact form submits via SMTP
- ✅ Calculator computes emissions
- ✅ Offset order flow works
- ✅ Mobile app route renders without header
- ✅ Stripe checkout redirects work
### SEO Tests
- ✅ Metadata appears in `<head>`
- ✅ Sitemap accessible at `/sitemap.xml`
- ✅ Robots.txt accessible at `/robots.txt`
- ✅ Open Graph tags present
### Environment Tests
- ✅ Development with `npm run dev`
- ✅ Production build with `npm run build`
- ✅ Environment variables load correctly
## Deployment Instructions
### Development
```bash
npm run dev
# Runs on http://localhost:3000
```
### Production Build
```bash
npm run build
npm start
# Standalone server runs on port 3000
```
### Docker Build
```bash
docker build -t puffin-app:latest .
docker run -p 3000:3000 \
-e NEXT_PUBLIC_WREN_API_TOKEN=your_token \
-e NEXT_PUBLIC_API_BASE_URL=https://api.puffinoffset.com \
puffin-app:latest
```
### Docker Compose
```bash
docker-compose up -d
# Frontend on port 3800, Backend on port 3801
```
## Rollback Plan
If issues arise, revert to Vite:
1. Check out previous commit before migration started
2. Run `npm install` (will restore Vite dependencies from package-lock.json)
3. Run `npm run dev` (Vite dev server)
4. Update environment variables back to `VITE_` prefix
## Support & Documentation
- **Next.js 16 Docs**: https://nextjs.org/docs
- **App Router Guide**: https://nextjs.org/docs/app
- **Environment Variables**: See `ENV_MIGRATION.md`
- **Troubleshooting**: Check dev server logs for errors
## Migration Completed By
Claude Code Agent
Date: January 31, 2025
Next.js Version: 16.0.1
React Version: 18.3.1
---
**Status**: ✅ **PRODUCTION READY**
All phases completed successfully. Application tested and verified working.

View File

@ -1,374 +0,0 @@
/**
* NocoDB Client - Clean abstraction layer for NocoDB REST API
* Hides query syntax complexity behind simple TypeScript functions
*/
interface NocoDBConfig {
baseUrl: string;
baseId: string;
apiKey: string;
ordersTableId: string;
}
interface OrderFilters {
status?: string;
vesselName?: string;
imoNumber?: string;
dateFrom?: string;
dateTo?: string;
minAmount?: number;
maxAmount?: number;
}
interface PaginationParams {
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
interface NocoDBResponse<T> {
list: T[];
pageInfo: {
totalRows: number;
page: number;
pageSize: number;
isFirstPage: boolean;
isLastPage: boolean;
};
}
interface OrderStats {
totalOrders: number;
totalRevenue: number;
totalCO2Offset: number;
fulfillmentRate: number;
ordersByStatus: {
pending: number;
paid: number;
fulfilled: number;
cancelled: number;
};
}
export class NocoDBClient {
private config: NocoDBConfig;
private baseUrl: string;
constructor() {
this.config = {
baseUrl: process.env.NOCODB_BASE_URL || '',
baseId: process.env.NOCODB_BASE_ID || '',
apiKey: process.env.NOCODB_API_KEY || '',
ordersTableId: process.env.NOCODB_ORDERS_TABLE_ID || '',
};
if (!this.config.baseUrl || !this.config.baseId || !this.config.apiKey || !this.config.ordersTableId) {
console.warn('NocoDB configuration incomplete. Some features may not work.');
}
this.baseUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records`;
}
/**
* Build NocoDB where clause from filters
* Note: Date filtering is done client-side since NocoDB columns are strings
*/
private buildWhereClause(filters: OrderFilters): string {
const conditions: string[] = [];
if (filters.status) {
conditions.push(`(status,eq,${filters.status})`);
}
if (filters.vesselName) {
conditions.push(`(vesselName,like,%${filters.vesselName}%)`);
}
if (filters.imoNumber) {
conditions.push(`(imoNumber,eq,${filters.imoNumber})`);
}
// Date filtering removed from WHERE clause - done client-side instead
// NocoDB rejects date comparisons when columns are stored as strings
if (filters.minAmount !== undefined) {
conditions.push(`(totalAmount,gte,${filters.minAmount})`);
}
if (filters.maxAmount !== undefined) {
conditions.push(`(totalAmount,lte,${filters.maxAmount})`);
}
return conditions.length > 0 ? conditions.join('~and') : '';
}
/**
* Filter orders by date range (client-side)
*/
private filterOrdersByDate(orders: any[], dateFrom?: string, dateTo?: string): any[] {
// Check for empty strings or undefined/null
const hasDateFrom = dateFrom && dateFrom.trim() !== '';
const hasDateTo = dateTo && dateTo.trim() !== '';
if (!hasDateFrom && !hasDateTo) return orders;
return orders.filter(order => {
const orderDate = new Date(order.CreatedAt);
if (hasDateFrom) {
const fromDate = new Date(dateFrom);
fromDate.setHours(0, 0, 0, 0);
if (orderDate < fromDate) return false;
}
if (hasDateTo) {
const toDate = new Date(dateTo);
toDate.setHours(23, 59, 59, 999);
if (orderDate > toDate) return false;
}
return true;
});
}
/**
* Build sort parameter
*/
private buildSortParam(sortBy?: string, sortOrder?: 'asc' | 'desc'): string {
if (!sortBy) return '-CreatedAt'; // Default: newest first
const prefix = sortOrder === 'asc' ? '' : '-';
return `${prefix}${sortBy}`;
}
/**
* Make authenticated request to NocoDB
*/
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'xc-token': this.config.apiKey,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`NocoDB request failed: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Get list of orders with filtering, sorting, and pagination
*/
async getOrders(
filters: OrderFilters = {},
pagination: PaginationParams = {}
): Promise<NocoDBResponse<any>> {
const params = new URLSearchParams();
// Add where clause if filters exist (excludes date filters)
const whereClause = this.buildWhereClause(filters);
if (whereClause) {
params.append('where', whereClause);
}
// Add sorting
const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder);
params.append('sort', sort);
// Fetch all records for client-side date filtering (up to 10000)
params.append('limit', '10000');
const queryString = params.toString();
const endpoint = queryString ? `?${queryString}` : '';
const response = await this.request<NocoDBResponse<any>>(endpoint);
// Apply client-side date filtering
const filteredOrders = this.filterOrdersByDate(
response.list,
filters.dateFrom,
filters.dateTo
);
// Apply pagination to filtered results
const requestedLimit = pagination.limit || 25;
const requestedOffset = pagination.offset || 0;
const paginatedOrders = filteredOrders.slice(
requestedOffset,
requestedOffset + requestedLimit
);
// Update response with filtered and paginated results
return {
list: paginatedOrders,
pageInfo: {
totalRows: filteredOrders.length,
page: Math.floor(requestedOffset / requestedLimit) + 1,
pageSize: requestedLimit,
isFirstPage: requestedOffset === 0,
isLastPage: requestedOffset + requestedLimit >= filteredOrders.length,
},
};
}
/**
* Get single order by ID
*/
async getOrderById(recordId: string): Promise<any> {
return this.request<any>(`/${recordId}`);
}
/**
* Search orders by text (searches vessel name, IMO, order ID)
*/
async searchOrders(
searchTerm: string,
pagination: PaginationParams = {}
): Promise<NocoDBResponse<any>> {
// Search in multiple fields using OR conditions
const searchConditions = [
`(vesselName,like,%${searchTerm}%)`,
`(imoNumber,like,%${searchTerm}%)`,
`(orderId,like,%${searchTerm}%)`,
].join('~or');
const params = new URLSearchParams();
params.append('where', searchConditions);
const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder);
params.append('sort', sort);
if (pagination.limit) {
params.append('limit', pagination.limit.toString());
}
if (pagination.offset) {
params.append('offset', pagination.offset.toString());
}
return this.request<NocoDBResponse<any>>(`?${params.toString()}`);
}
/**
* Get order statistics for dashboard
*/
async getStats(dateFrom?: string, dateTo?: string): Promise<OrderStats> {
// Fetch all orders without date filtering
const response = await this.getOrders({}, { limit: 10000 });
// Apply client-side date filtering
const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo);
// Calculate stats (parse string amounts from NocoDB)
const totalOrders = orders.length;
const totalRevenue = orders.reduce((sum, order) => sum + parseFloat(order.totalAmount || '0'), 0);
const totalCO2Offset = orders.reduce((sum, order) => sum + parseFloat(order.co2Tons || '0'), 0);
const ordersByStatus = {
pending: orders.filter((o) => o.status === 'pending').length,
paid: orders.filter((o) => o.status === 'paid').length,
fulfilled: orders.filter((o) => o.status === 'fulfilled').length,
cancelled: orders.filter((o) => o.status === 'cancelled').length,
};
const fulfillmentRate =
totalOrders > 0 ? (ordersByStatus.fulfilled / totalOrders) * 100 : 0;
return {
totalOrders,
totalRevenue,
totalCO2Offset,
fulfillmentRate,
ordersByStatus,
};
}
/**
* Update order status
*/
async updateOrderStatus(recordId: string, status: string): Promise<any> {
return this.request<any>(`/${recordId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
}
/**
* Update order fields
*/
async updateOrder(recordId: string, data: Record<string, any>): Promise<any> {
return this.request<any>(`/${recordId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
/**
* Get orders grouped by time period (for charts)
*/
async getOrdersTimeline(
period: 'day' | 'week' | 'month',
dateFrom?: string,
dateTo?: string
): Promise<Array<{ date: string; count: number; revenue: number }>> {
// Fetch all orders without date filtering
const response = await this.getOrders({}, { limit: 10000 });
// Apply client-side date filtering
const orders = this.filterOrdersByDate(response.list, dateFrom, dateTo);
// Group orders by time period
const grouped = new Map<string, { count: number; revenue: number }>();
orders.forEach((order) => {
const date = new Date(order.CreatedAt);
let key: string;
switch (period) {
case 'day':
key = date.toISOString().split('T')[0];
break;
case 'week':
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay());
key = weekStart.toISOString().split('T')[0];
break;
case 'month':
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
break;
}
const existing = grouped.get(key) || { count: 0, revenue: 0 };
grouped.set(key, {
count: existing.count + 1,
revenue: existing.revenue + parseFloat(order.totalAmount || '0'),
});
});
return Array.from(grouped.entries())
.map(([date, data]) => ({ date, ...data }))
.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Get count of records matching filters
*/
async getCount(filters: OrderFilters = {}): Promise<number> {
// Fetch all orders and count after client-side filtering
const response = await this.getOrders(filters, { limit: 10000 });
return response.list.length;
}
}
// Export singleton instance
export const nocodbClient = new NocoDBClient();
// Export types for use in other files
export type { OrderFilters, PaginationParams, NocoDBResponse, OrderStats };

View File

@ -1,18 +0,0 @@
import type { Metadata } from 'next';
import { AboutClient } from '../../components/AboutClient';
export const metadata: Metadata = {
title: 'About',
description: 'Learn about Puffin Offset\'s mission to make carbon offsetting accessible and effective for the maritime industry. Discover our values of transparency, quality, partnership, and innovation.',
keywords: ['carbon offsetting', 'maritime sustainability', 'yacht carbon offsets', 'marine environmental impact', 'sustainable yachting'],
openGraph: {
title: 'About Puffin Offset - Maritime Carbon Offsetting Solutions',
description: 'Leading the way in maritime carbon offsetting with transparent, verified projects for yacht owners and operators.',
type: 'website',
url: 'https://puffinoffset.com/about',
},
};
export default function AboutPage() {
return <AboutClient />;
}

View File

@ -1,50 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { AdminSidebar } from '@/components/admin/AdminSidebar';
export default function AdminLayoutClient({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
useEffect(() => {
// Skip auth check for login page
if (pathname === '/admin/login') {
return;
}
// Check authentication
const checkAuth = async () => {
try {
const response = await fetch('/api/admin/auth/verify');
if (!response.ok) {
router.push('/admin/login');
}
} catch (error) {
router.push('/admin/login');
}
};
checkAuth();
}, [pathname, router]);
// If on login page, render full-screen without sidebar
if (pathname === '/admin/login') {
return <>{children}</>;
}
// Dashboard/orders pages with sidebar
return (
<div className="min-h-screen bg-sail-white">
<AdminSidebar />
<main className="ml-64 p-8">
{children}
</main>
</div>
);
}

View File

@ -1,281 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { DollarSign, Package, Leaf, TrendingUp, Loader2 } from 'lucide-react';
import {
LineChart,
Line,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface DashboardStats {
totalOrders: number;
totalRevenue: number;
totalCO2Offset: number;
fulfillmentRate: number;
ordersByStatus: {
pending: number;
paid: number;
fulfilled: number;
cancelled: number;
};
}
interface TimelineData {
date: string;
count: number;
revenue: number;
}
type TimeRange = '7d' | '30d' | '90d' | 'all';
export default function AdminDashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [timeline, setTimeline] = useState<TimelineData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [timeRange, setTimeRange] = useState<TimeRange>('30d');
const [error, setError] = useState<string | null>(null);
// Fetch dashboard data
useEffect(() => {
fetchDashboardData();
}, [timeRange]);
const fetchDashboardData = async () => {
setIsLoading(true);
setError(null);
try {
// Calculate date range
const dateTo = new Date().toISOString().split('T')[0];
let dateFrom: string | undefined;
if (timeRange !== 'all') {
const days = timeRange === '7d' ? 7 : timeRange === '30d' ? 30 : 90;
const fromDate = new Date();
fromDate.setDate(fromDate.getDate() - days);
dateFrom = fromDate.toISOString().split('T')[0];
}
// Build query params
const params = new URLSearchParams();
if (dateFrom) params.append('dateFrom', dateFrom);
params.append('dateTo', dateTo);
params.append('period', timeRange === '7d' ? 'day' : 'week');
const response = await fetch(`/api/admin/stats?${params}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch dashboard data');
}
setStats(data.data.stats);
setTimeline(data.data.timeline);
} catch (err) {
console.error('Error fetching dashboard data:', err);
setError(err instanceof Error ? err.message : 'Failed to load dashboard');
} finally {
setIsLoading(false);
}
};
// Prepare pie chart data
const pieChartData = stats
? [
{ name: 'Pending', value: stats.ordersByStatus.pending, color: '#D68910' },
{ name: 'Paid', value: stats.ordersByStatus.paid, color: '#008B8B' },
{ name: 'Fulfilled', value: stats.ordersByStatus.fulfilled, color: '#1E8449' },
{ name: 'Cancelled', value: stats.ordersByStatus.cancelled, color: '#DC2626' },
]
: [];
const statCards = stats
? [
{
title: 'Total Orders',
value: stats.totalOrders.toLocaleString(),
icon: <Package size={24} />,
gradient: 'bg-gradient-to-br from-royal-purple to-purple-600',
},
{
title: 'Total CO₂ Offset',
value: `${stats.totalCO2Offset.toFixed(2)} tons`,
icon: <Leaf size={24} />,
gradient: 'bg-gradient-to-br from-sea-green to-green-600',
},
{
title: 'Total Revenue',
value: `$${(stats.totalRevenue / 100).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`,
icon: <DollarSign size={24} />,
gradient: 'bg-gradient-to-br from-muted-gold to-orange-600',
},
{
title: 'Fulfillment Rate',
value: `${stats.fulfillmentRate.toFixed(1)}%`,
icon: <TrendingUp size={24} />,
gradient: 'bg-gradient-to-br from-maritime-teal to-teal-600',
},
]
: [];
if (error) {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Dashboard</h1>
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
<p className="text-red-800 font-medium">{error}</p>
<button
onClick={fetchDashboardData}
className="mt-4 px-4 py-2 bg-deep-sea-blue text-white rounded-lg hover:bg-deep-sea-blue/90"
>
Retry
</button>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Header with Time Range Selector */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Dashboard</h1>
<p className="text-deep-sea-blue/70 font-medium">Overview of your carbon offset operations</p>
</div>
<div className="flex items-center gap-2 bg-white border border-light-gray-border rounded-lg p-1">
{(['7d', '30d', '90d', 'all'] as TimeRange[]).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-4 py-2 rounded-md font-medium transition-all ${
timeRange === range
? 'bg-deep-sea-blue text-white shadow-sm'
: 'text-deep-sea-blue/60 hover:text-deep-sea-blue hover:bg-sail-white'
}`}
>
{range === 'all' ? 'All Time' : range.toUpperCase()}
</button>
))}
</div>
</div>
{/* Loading State */}
{isLoading ? (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-deep-sea-blue animate-spin" />
</div>
) : (
<>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statCards.map((stat, index) => (
<motion.div
key={stat.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="bg-white border border-light-gray-border rounded-xl p-6 hover:shadow-lg transition-shadow"
>
<div className="flex items-center justify-between mb-4">
<div
className={
stat.gradient + ' w-12 h-12 rounded-lg flex items-center justify-center text-white shadow-md'
}
>
{stat.icon}
</div>
</div>
<h3 className="text-sm font-medium text-deep-sea-blue/60 mb-1">{stat.title}</h3>
<p className="text-2xl font-bold text-deep-sea-blue">{stat.value}</p>
</motion.div>
))}
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Orders Timeline */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="bg-white border border-light-gray-border rounded-xl p-6"
>
<h2 className="text-xl font-bold text-deep-sea-blue mb-4">Orders Over Time</h2>
{timeline.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={timeline}>
<CartesianGrid strokeDasharray="3 3" stroke="#EAECF0" />
<XAxis dataKey="date" stroke="#1D2939" fontSize={12} />
<YAxis stroke="#1D2939" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#FFF',
border: '1px solid #EAECF0',
borderRadius: '8px',
}}
/>
<Legend />
<Line type="monotone" dataKey="count" stroke="#884EA0" name="Orders" strokeWidth={2} />
<Line type="monotone" dataKey="revenue" stroke="#1E8449" name="Revenue ($)" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-deep-sea-blue/60 font-medium">
No orders data available for this time range
</div>
)}
</motion.div>
{/* Status Distribution */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="bg-white border border-light-gray-border rounded-xl p-6"
>
<h2 className="text-xl font-bold text-deep-sea-blue mb-4">Status Distribution</h2>
{stats && stats.totalOrders > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={pieChartData}
cx="50%"
cy="50%"
labelLine={false}
label={false}
outerRadius={100}
fill="#8884d8"
dataKey="value"
>
{pieChartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-deep-sea-blue/60 font-medium">
No orders to display
</div>
)}
</motion.div>
</div>
</>
)}
</div>
);
}

View File

@ -1,22 +0,0 @@
import type { Metadata } from 'next';
import AdminLayoutClient from './AdminLayoutClient';
export const metadata: Metadata = {
title: {
default: 'Admin Portal | Puffin Offset',
template: '%s | Admin Portal',
},
description: 'Admin management portal for Puffin Offset',
robots: {
index: false,
follow: false,
},
};
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return <AdminLayoutClient>{children}</AdminLayoutClient>;
}

View File

@ -1,201 +0,0 @@
'use client';
import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { Lock, User, Loader2 } from 'lucide-react';
import { motion } from 'framer-motion';
import Image from 'next/image';
export default function AdminLogin() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
const response = await fetch('/api/admin/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (!response.ok) {
setError(data.error || 'Login failed');
setIsLoading(false);
return;
}
// Successful login - redirect to dashboard
router.push('/admin/dashboard');
} catch (err) {
setError('Network error. Please try again.');
setIsLoading(false);
}
};
return (
<div className="min-h-screen w-full flex items-center justify-center p-4 relative overflow-hidden">
{/* Monaco Background Image */}
<div
className="absolute inset-0 w-full h-full bg-cover bg-center"
style={{
backgroundImage: 'url(/monaco_high_res.jpg)',
filter: 'brightness(0.6) contrast(1.1)'
}}
/>
{/* Overlay gradient for better readability */}
<div className="absolute inset-0 bg-gradient-to-b from-black/50 via-black/30 to-black/50" />
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="relative w-full max-w-md z-10"
>
{/* Logo and Title */}
<div className="text-center mb-8">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2, duration: 0.5 }}
className="flex justify-center mb-4"
>
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center shadow-lg p-2">
<Image
src="/puffinOffset.png"
alt="Puffin Offset Logo"
width={64}
height={64}
className="object-contain"
/>
</div>
</motion.div>
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3, duration: 0.5 }}
className="text-3xl font-bold mb-2 text-off-white"
>
Admin Portal
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="font-medium text-off-white/80"
>
Puffin Offset Management
</motion.p>
</div>
{/* Login Card */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5, duration: 0.5 }}
className="bg-white border border-light-gray-border rounded-xl p-8 shadow-2xl"
>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Error Message */}
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="bg-red-900/30 border border-red-800/60 rounded-lg p-3 text-red-100 text-sm"
>
{error}
</motion.div>
)}
{/* Username Field */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-deep-sea-blue mb-2">
Username
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-deep-sea-blue/60" />
</div>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-white border border-light-gray-border rounded-lg text-deep-sea-blue placeholder-deep-sea-blue/40 focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/50 focus:border-deep-sea-blue transition-all"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-deep-sea-blue mb-2">
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-deep-sea-blue/60" />
</div>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-white border border-light-gray-border rounded-lg text-deep-sea-blue placeholder-deep-sea-blue/40 focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/50 focus:border-deep-sea-blue transition-all"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
</div>
{/* Submit Button */}
<motion.button
type="submit"
disabled={isLoading}
whileHover={{ scale: isLoading ? 1 : 1.02 }}
whileTap={{ scale: isLoading ? 1 : 0.98 }}
className={`w-full py-3 px-4 rounded-lg font-semibold text-white shadow-lg transition-all ${
isLoading
? 'bg-deep-sea-blue/50 cursor-not-allowed'
: 'bg-deep-sea-blue hover:bg-deep-sea-blue/90'
}`}
>
{isLoading ? (
<span className="flex items-center justify-center">
<Loader2 className="animate-spin mr-2" size={20} />
Signing in...
</span>
) : (
'Sign In'
)}
</motion.button>
</form>
</motion.div>
{/* Footer */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.5 }}
className="text-center mt-6 text-sm font-medium text-off-white/70"
>
© 2025 Puffin Offset. Secure admin access.
</motion.p>
</motion.div>
</div>
);
}

View File

@ -1,231 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
import { OrderStatsCards } from '@/components/admin/OrderStatsCards';
import { OrdersTable } from '@/components/admin/OrdersTable';
import { OrderFilters } from '@/components/admin/OrderFilters';
import { OrderDetailsModal } from '@/components/admin/OrderDetailsModal';
import { ExportButton } from '@/components/admin/ExportButton';
import { OrderRecord } from '@/src/types';
interface OrderStats {
totalOrders: number;
totalRevenue: number;
totalCO2Offset: number;
fulfillmentRate: number;
}
export default function AdminOrders() {
const [orders, setOrders] = useState<OrderRecord[]>([]);
const [stats, setStats] = useState<OrderStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
const [sortKey, setSortKey] = useState<keyof OrderRecord | null>('CreatedAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [selectedOrder, setSelectedOrder] = useState<OrderRecord | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
// Fetch orders and stats
useEffect(() => {
fetchData();
}, [currentPage, pageSize, sortKey, sortOrder, searchTerm, statusFilter, dateFrom, dateTo]);
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
// Build query params for orders
const params = new URLSearchParams();
params.append('limit', pageSize.toString());
params.append('offset', ((currentPage - 1) * pageSize).toString());
if (sortKey) {
params.append('sortBy', sortKey);
params.append('sortOrder', sortOrder);
}
// Add filters
if (searchTerm) {
params.append('search', searchTerm);
}
if (statusFilter) {
params.append('status', statusFilter);
}
if (dateFrom) {
params.append('dateFrom', dateFrom);
}
if (dateTo) {
params.append('dateTo', dateTo);
}
// Fetch orders
const ordersResponse = await fetch(`/api/admin/orders?${params}`);
const ordersData = await ordersResponse.json();
if (!ordersResponse.ok) {
throw new Error(ordersData.error || 'Failed to fetch orders');
}
setOrders(ordersData.data || []);
setTotalCount(ordersData.pagination?.totalRows || 0);
// Fetch stats (for current month)
const statsResponse = await fetch('/api/admin/stats');
const statsData = await statsResponse.json();
if (statsResponse.ok) {
setStats(statsData.data.stats);
}
} catch (err) {
console.error('Error fetching data:', err);
setError(err instanceof Error ? err.message : 'Failed to load orders');
} finally {
setIsLoading(false);
}
};
const handleSort = (key: keyof OrderRecord) => {
if (sortKey === key) {
// Toggle sort order if same column
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
// New column, default to ascending
setSortKey(key);
setSortOrder('asc');
}
};
const handlePageChange = (newPage: number) => {
setCurrentPage(newPage);
};
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
setCurrentPage(1); // Reset to first page when changing page size
};
const handleViewDetails = (order: OrderRecord) => {
setSelectedOrder(order);
setIsDetailsModalOpen(true);
};
const handleCloseDetailsModal = () => {
setIsDetailsModalOpen(false);
// Optional: clear selected order after animation completes
setTimeout(() => setSelectedOrder(null), 300);
};
const handleSearchChange = (search: string) => {
setSearchTerm(search);
setCurrentPage(1); // Reset to first page on filter change
};
const handleStatusChange = (status: string) => {
setStatusFilter(status);
setCurrentPage(1);
};
const handleDateRangeChange = (from: string, to: string) => {
setDateFrom(from);
setDateTo(to);
setCurrentPage(1);
};
const handleResetFilters = () => {
setSearchTerm('');
setStatusFilter('');
setDateFrom('');
setDateTo('');
setCurrentPage(1);
};
if (error) {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Orders</h1>
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
<p className="text-red-800 font-medium">{error}</p>
<button
onClick={fetchData}
className="mt-4 px-4 py-2 bg-deep-sea-blue text-white rounded-lg hover:bg-deep-sea-blue/90 transition-colors"
>
Retry
</button>
</div>
</div>
);
}
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="space-y-8"
>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-deep-sea-blue mb-2">Orders</h1>
<p className="text-deep-sea-blue/70 font-medium">
View and manage all carbon offset orders
</p>
</div>
<ExportButton
currentOrders={orders}
filters={{
search: searchTerm,
status: statusFilter,
dateFrom: dateFrom,
dateTo: dateTo,
}}
/>
</div>
{/* Stats Cards */}
<OrderStatsCards stats={stats} isLoading={isLoading && !stats} />
{/* Filters */}
<OrderFilters
onSearchChange={handleSearchChange}
onStatusChange={handleStatusChange}
onDateRangeChange={handleDateRangeChange}
onReset={handleResetFilters}
searchValue={searchTerm}
statusValue={statusFilter}
dateFromValue={dateFrom}
dateToValue={dateTo}
/>
{/* Orders Table */}
<OrdersTable
orders={orders}
isLoading={isLoading}
onViewDetails={handleViewDetails}
totalCount={totalCount}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSort={handleSort}
sortKey={sortKey}
sortOrder={sortOrder}
/>
{/* Order Details Modal */}
<OrderDetailsModal
order={selectedOrder}
isOpen={isDetailsModalOpen}
onClose={handleCloseDetailsModal}
/>
</motion.div>
);
}

View File

@ -1,24 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Loader2 } from 'lucide-react';
export default function AdminIndex() {
const router = useRouter();
useEffect(() => {
// Redirect to dashboard
// The AdminLayoutClient will handle auth check and redirect to login if needed
router.push('/admin/dashboard');
}, [router]);
return (
<div className="min-h-screen flex items-center justify-center bg-sail-white">
<div className="text-center">
<Loader2 className="w-8 h-8 text-deep-sea-blue animate-spin mx-auto mb-4" />
<p className="text-deep-sea-blue/70 font-medium">Redirecting to admin portal...</p>
</div>
</div>
);
}

View File

@ -1,59 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyCredentials, generateToken } from '@/lib/admin/auth';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
// Validate input
if (!username || !password) {
return NextResponse.json(
{ error: 'Username and password are required' },
{ status: 400 }
);
}
// Verify credentials
const isValid = verifyCredentials(username, password);
if (!isValid) {
return NextResponse.json(
{ error: 'Invalid credentials' },
{ status: 401 }
);
}
// Generate JWT token
const token = generateToken({
username,
isAdmin: true,
});
// Create response with token in cookie
const response = NextResponse.json({
success: true,
user: {
username,
isAdmin: true,
},
});
// Set HTTP-only cookie with JWT token
response.cookies.set('admin-token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
});
return response;
} catch (error) {
console.error('Login error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -1,16 +0,0 @@
import { NextResponse } from 'next/server';
export async function POST() {
const response = NextResponse.json({ success: true });
// Clear the admin token cookie
response.cookies.set('admin-token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0,
path: '/',
});
return response;
}

View File

@ -1,18 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAdminFromRequest } from '@/lib/admin/middleware';
export async function GET(request: NextRequest) {
const admin = getAdminFromRequest(request);
if (!admin) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
return NextResponse.json({
success: true,
user: admin,
});
}

View File

@ -1,88 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { nocodbClient } from '@/api/nocodbClient';
/**
* GET /api/admin/orders/[id]
* Get single order by record ID
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const order = await nocodbClient.getOrderById(id);
return NextResponse.json({
success: true,
data: order,
});
} catch (error) {
console.error('Error fetching order:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch order',
},
{ status: 500 }
);
}
}
/**
* PATCH /api/admin/orders/[id]
* Update order fields (commonly used for status updates)
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const updatedOrder = await nocodbClient.updateOrder(id, body);
return NextResponse.json({
success: true,
data: updatedOrder,
});
} catch (error) {
console.error('Error updating order:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to update order',
},
{ status: 500 }
);
}
}
/**
* DELETE /api/admin/orders/[id]
* Delete/archive an order
*/
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
// Instead of actually deleting, update status to "cancelled"
await nocodbClient.updateOrderStatus(id, 'cancelled');
return NextResponse.json({
success: true,
message: 'Order cancelled successfully',
});
} catch (error) {
console.error('Error cancelling order:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to cancel order',
},
{ status: 500 }
);
}
}

View File

@ -1,131 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { nocodbClient } from '@/api/nocodbClient';
import type { OrderFilters } from '@/api/nocodbClient';
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
/**
* GET /api/admin/orders/export
* Export orders to CSV or Excel
* Query params:
* - format: 'csv' | 'xlsx' (default: csv)
* - status: Filter by status
* - dateFrom, dateTo: Date range filter
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const format = searchParams.get('format') || 'csv';
// Build filters (same as orders list)
const filters: OrderFilters = {};
if (searchParams.get('status')) filters.status = searchParams.get('status')!;
if (searchParams.get('dateFrom')) filters.dateFrom = searchParams.get('dateFrom')!;
if (searchParams.get('dateTo')) filters.dateTo = searchParams.get('dateTo')!;
// Get all matching orders (no pagination for export)
const response = await nocodbClient.getOrders(filters, { limit: 10000 });
const orders = response.list;
// Transform data for export
const exportData = orders.map((order) => ({
'Order ID': order.orderId,
'Status': order.status,
'Created Date': order.CreatedAt ? new Date(order.CreatedAt).toLocaleDateString() : '',
'Vessel Name': order.vesselName,
'IMO Number': order.imoNumber || '',
'Distance (NM)': order.distance || '',
'Avg Speed (kn)': order.avgSpeed || '',
'CO2 Tons': order.co2Tons || '',
'Total Amount': order.totalAmount || '',
'Currency': order.currency || '',
'Customer Name': order.customerName || '',
'Customer Email': order.customerEmail || '',
'Customer Company': order.customerCompany || '',
'Departure Port': order.departurePort || '',
'Arrival Port': order.arrivalPort || '',
'Payment Method': order.paymentMethod || '',
'Payment Reference': order.paymentReference || '',
'Wren Order ID': order.wrenOrderId || '',
'Certificate URL': order.certificateUrl || '',
'Fulfilled At': order.fulfilledAt ? new Date(order.fulfilledAt).toLocaleDateString() : '',
'Notes': order.notes || '',
}));
if (format === 'xlsx') {
// Generate Excel file
const worksheet = XLSX.utils.json_to_sheet(exportData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Orders');
// Style headers (make them bold)
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1');
for (let col = range.s.c; col <= range.e.c; col++) {
const cellAddress = XLSX.utils.encode_cell({ r: 0, c: col });
if (!worksheet[cellAddress]) continue;
worksheet[cellAddress].s = {
font: { bold: true },
fill: { fgColor: { rgb: 'D3D3D3' } },
};
}
// Set column widths
worksheet['!cols'] = [
{ wch: 20 }, // Order ID
{ wch: 12 }, // Status
{ wch: 15 }, // Created Date
{ wch: 25 }, // Vessel Name
{ wch: 12 }, // IMO Number
{ wch: 12 }, // Distance
{ wch: 12 }, // Avg Speed
{ wch: 10 }, // CO2 Tons
{ wch: 12 }, // Total Amount
{ wch: 10 }, // Currency
{ wch: 20 }, // Customer Name
{ wch: 25 }, // Customer Email
{ wch: 25 }, // Customer Company
{ wch: 20 }, // Departure Port
{ wch: 20 }, // Arrival Port
{ wch: 15 }, // Payment Method
{ wch: 20 }, // Payment Reference
{ wch: 20 }, // Wren Order ID
{ wch: 30 }, // Certificate URL
{ wch: 15 }, // Fulfilled At
{ wch: 40 }, // Notes
];
// Generate buffer
const buffer = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' });
// Return Excel file
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="puffin-orders-${Date.now()}.xlsx"`,
},
});
} else {
// Generate CSV
const csv = Papa.unparse(exportData);
// Return CSV file
return new NextResponse(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="puffin-orders-${Date.now()}.csv"`,
},
});
}
} catch (error) {
console.error('Error exporting orders:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to export orders',
},
{ status: 500 }
);
}
}

View File

@ -1,85 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { nocodbClient } from '@/api/nocodbClient';
import type { OrderFilters, PaginationParams } from '@/api/nocodbClient';
/**
* GET /api/admin/orders
* Get list of orders with filtering, sorting, and pagination
* Query params:
* - search: Text search across vessel name, IMO, order ID
* - status: Filter by order status
* - dateFrom, dateTo: Date range filter
* - limit: Page size (default: 50)
* - offset: Pagination offset
* - sortBy: Field to sort by
* - sortOrder: 'asc' | 'desc'
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const searchTerm = searchParams.get('search');
// Build filters
const filters: OrderFilters = {};
if (searchParams.get('status')) filters.status = searchParams.get('status')!;
if (searchParams.get('dateFrom')) filters.dateFrom = searchParams.get('dateFrom')!;
if (searchParams.get('dateTo')) filters.dateTo = searchParams.get('dateTo')!;
if (searchParams.get('vesselName')) filters.vesselName = searchParams.get('vesselName')!;
if (searchParams.get('imoNumber')) filters.imoNumber = searchParams.get('imoNumber')!;
// Build pagination
const pagination: PaginationParams = {
limit: parseInt(searchParams.get('limit') || '50'),
offset: parseInt(searchParams.get('offset') || '0'),
sortBy: searchParams.get('sortBy') || 'CreatedAt',
sortOrder: (searchParams.get('sortOrder') as 'asc' | 'desc') || 'desc',
};
// Use search if provided, otherwise use filters
const response = searchTerm
? await nocodbClient.searchOrders(searchTerm, pagination)
: await nocodbClient.getOrders(filters, pagination);
return NextResponse.json({
success: true,
data: response.list,
pagination: response.pageInfo,
});
} catch (error) {
console.error('Error fetching orders:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch orders',
},
{ status: 500 }
);
}
}
/**
* POST /api/admin/orders
* Create a new order (if needed for manual entry)
*/
export async function POST(_request: NextRequest) {
try {
// In a real implementation, you'd call nocodbClient to create the order
// For now, return a placeholder
return NextResponse.json(
{
success: false,
error: 'Order creation not yet implemented',
},
{ status: 501 }
);
} catch (error) {
console.error('Error creating order:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create order',
},
{ status: 500 }
);
}
}

View File

@ -1,42 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { nocodbClient } from '@/api/nocodbClient';
/**
* GET /api/admin/stats
* Get dashboard statistics with optional time range filtering
* Query params:
* - dateFrom: ISO date string (e.g., "2024-01-01")
* - dateTo: ISO date string
* - period: 'day' | 'week' | 'month' (for timeline data)
*/
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const dateFrom = searchParams.get('dateFrom') || undefined;
const dateTo = searchParams.get('dateTo') || undefined;
const period = (searchParams.get('period') || 'day') as 'day' | 'week' | 'month';
// Get overall stats
const stats = await nocodbClient.getStats(dateFrom, dateTo);
// Get timeline data for charts
const timeline = await nocodbClient.getOrdersTimeline(period, dateFrom, dateTo);
return NextResponse.json({
success: true,
data: {
stats,
timeline,
},
});
} catch (error) {
console.error('Error fetching admin stats:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch statistics',
},
{ status: 500 }
);
}
}

View File

@ -1,93 +0,0 @@
/**
* QR Code Generation API Endpoint
* POST /api/qr-code/generate
*/
import { NextRequest, NextResponse } from 'next/server';
import { QRCalculatorData, QRGenerationResponse } from '@/src/types';
import { validateQRData, sanitizeQRData } from '@/src/utils/qrDataValidator';
import { generateCalculatorQRCode } from '@/src/utils/qrCodeGenerator';
export async function POST(request: NextRequest) {
console.log('[QR API] POST /api/qr-code/generate - Request received');
try {
// Parse request body
const body = await request.json();
console.log('[QR API] Request body parsed:', JSON.stringify(body, null, 2));
// Validate data
console.log('[QR API] Validating QR data...');
const validationResult = validateQRData(body);
if (!validationResult.valid) {
console.error('[QR API] Validation failed:', validationResult.error);
return NextResponse.json(
{
success: false,
error: validationResult.error || 'Invalid QR data',
},
{ status: 400 }
);
}
console.log('[QR API] Validation successful');
const data = validationResult.data as QRCalculatorData;
// Sanitize data to remove any unnecessary fields
const cleanedData = sanitizeQRData(data);
console.log('[QR API] Data sanitized');
// Get base URL from request
const protocol = request.headers.get('x-forwarded-proto') || 'https';
const host = request.headers.get('host') || 'localhost:3000';
const baseUrl = `${protocol}://${host}`;
console.log('[QR API] Base URL:', baseUrl);
// Generate QR code
console.log('[QR API] Generating QR code...');
const { dataURL, svg, url } = await generateCalculatorQRCode(cleanedData, baseUrl);
console.log('[QR API] QR code generated successfully, URL:', url);
// Set expiration time (30 days from now)
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
// Prepare response
const response: QRGenerationResponse = {
qrCodeDataURL: dataURL,
qrCodeSVG: svg,
url: url,
expiresAt: expiresAt.toISOString(),
};
console.log('[QR API] Sending success response');
return NextResponse.json({
success: true,
data: response,
});
} catch (error) {
console.error('[QR API] Error generating QR code:', error);
console.error('[QR API] Error stack:', error instanceof Error ? error.stack : 'No stack trace');
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to generate QR code',
},
{ status: 500 }
);
}
}
// Return method not allowed for other HTTP methods
export async function GET() {
console.log('[QR API] GET /api/qr-code/generate - Method not allowed');
return NextResponse.json(
{
success: false,
error: 'Method not allowed. Use POST to generate QR codes.',
},
{ status: 405 }
);
}

View File

@ -1,79 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* POST /api/wren/offset-orders
* Proxy endpoint to create offset orders with Wren API
* This keeps the WREN_API_TOKEN secure on the server
*
* Request body:
* {
* tons: number,
* portfolioId: number,
* dryRun?: boolean,
* source?: string,
* note?: string
* }
*/
export async function POST(request: NextRequest) {
try {
const apiToken = process.env.WREN_API_TOKEN;
if (!apiToken) {
console.error('WREN_API_TOKEN is not configured');
return NextResponse.json(
{ error: 'Wren API is not configured' },
{ status: 500 }
);
}
// Parse request body
const body = await request.json();
const { tons, portfolioId, dryRun = false, source, note } = body;
// Validate required fields
if (!tons || !portfolioId) {
return NextResponse.json(
{ error: 'Missing required fields: tons and portfolioId' },
{ status: 400 }
);
}
// Create offset order payload
const orderPayload: any = {
tons,
portfolio_id: portfolioId,
dry_run: dryRun,
};
if (source) orderPayload.source = source;
if (note) orderPayload.note = note;
// Make request to Wren API
const response = await fetch('https://www.wren.co/api/offset_orders', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(orderPayload),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Wren API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to create offset order with Wren' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Error creating Wren offset order:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to create offset order' },
{ status: 500 }
);
}
}

View File

@ -1,45 +0,0 @@
import { NextResponse } from 'next/server';
/**
* GET /api/wren/portfolios
* Proxy endpoint to fetch portfolios from Wren API
* This keeps the WREN_API_TOKEN secure on the server
*/
export async function GET() {
try {
const apiToken = process.env.WREN_API_TOKEN;
if (!apiToken) {
console.error('WREN_API_TOKEN is not configured');
return NextResponse.json(
{ error: 'Wren API is not configured' },
{ status: 500 }
);
}
const response = await fetch('https://www.wren.co/api/portfolios', {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('Wren API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to fetch portfolios from Wren' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Error fetching Wren portfolios:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch portfolios' },
{ status: 500 }
);
}
}

View File

@ -1,21 +0,0 @@
import type { Metadata } from 'next';
import { CalculatorClient } from '../../components/CalculatorClient';
// Force dynamic rendering since we need to read URL search parameters
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Carbon Calculator',
description: 'Calculate your yacht\'s carbon footprint and purchase verified carbon offsets. Enter fuel usage or nautical miles to get accurate CO2 emissions calculations and offset options.',
keywords: ['carbon calculator', 'yacht emissions', 'CO2 calculator', 'fuel emissions', 'carbon footprint', 'offset calculator'],
openGraph: {
title: 'Yacht Carbon Calculator - Calculate & Offset Emissions',
description: 'Calculate your yacht\'s carbon footprint based on fuel consumption or distance traveled, then offset through verified projects.',
type: 'website',
url: 'https://puffinoffset.com/calculator',
},
};
export default function CalculatorPage() {
return <CalculatorClient />;
}

View File

@ -1,395 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { Leaf } from 'lucide-react';
import { CarbonImpactComparison } from '../../../../src/components/CarbonImpactComparison';
import { RechartsPortfolioPieChart } from '../../../../src/components/RechartsPortfolioPieChart';
import { useRouter } from 'next/navigation';
// Mock order data for demo purposes
const mockOrderDetails = {
order: {
id: 'demo_order_123',
tons: 5.2,
portfolioId: 1,
baseAmount: 9360, // $93.60 in cents
processingFee: 281, // $2.81 in cents
totalAmount: 9641, // $96.41 in cents
currency: 'USD',
status: 'paid',
wrenOrderId: 'wren_abc123xyz',
stripeSessionId: 'cs_test_demo123',
createdAt: new Date().toISOString(),
portfolio: {
id: 1,
name: 'Puffin Maritime Carbon Portfolio',
description: 'Curated mix of high-impact verified carbon offset projects',
projects: [
{
id: '1',
name: 'Rimba Raya Biodiversity Reserve',
type: 'Forestry',
description: 'Protecting 64,000 hectares of tropical peat swamp forest in Borneo',
shortDescription: 'Tropical forest conservation in Borneo',
pricePerTon: 15,
percentage: 0.3,
imageUrl: '/projects/forest.jpg',
location: 'Borneo, Indonesia',
verificationStandard: 'VCS, CCB',
impactMetrics: {
co2Reduced: 3500000
}
},
{
id: '2',
name: 'Verified Blue Carbon',
type: 'Blue Carbon',
description: 'Coastal wetland restoration for carbon sequestration',
shortDescription: 'Coastal wetland restoration',
pricePerTon: 25,
percentage: 0.25,
imageUrl: '/projects/ocean.jpg',
location: 'Global',
verificationStandard: 'VCS',
impactMetrics: {
co2Reduced: 1200000
}
},
{
id: '3',
name: 'Direct Air Capture Technology',
type: 'Direct Air Capture',
description: 'Advanced technology removing CO2 directly from atmosphere',
shortDescription: 'Direct air capture technology',
pricePerTon: 35,
percentage: 0.2,
imageUrl: '/projects/tech.jpg',
location: 'Iceland',
verificationStandard: 'ISO 14064',
impactMetrics: {
co2Reduced: 500000
}
},
{
id: '4',
name: 'Renewable Energy Development',
type: 'Renewable Energy',
description: 'Wind and solar power generation replacing fossil fuels',
shortDescription: 'Clean energy generation',
pricePerTon: 12,
percentage: 0.25,
imageUrl: '/projects/solar.jpg',
location: 'Global',
verificationStandard: 'Gold Standard',
impactMetrics: {
co2Reduced: 2800000
}
}
]
}
},
session: {
paymentStatus: 'paid',
customerEmail: 'demo@puffinoffset.com'
}
};
// Map backend status to user-friendly labels
const getStatusDisplay = (status: string): { label: string; className: string } => {
switch (status) {
case 'paid':
case 'fulfilled':
return { label: 'Confirmed', className: 'bg-green-100 text-green-700' };
case 'pending':
return { label: 'Processing', className: 'bg-yellow-100 text-yellow-700' };
default:
return { label: status.toUpperCase(), className: 'bg-slate-100 text-slate-700' };
}
};
// Format currency with commas
const formatCurrency = (amount: number): string => {
return amount.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
};
export default function CheckoutSuccessDemoPage() {
const router = useRouter();
const orderDetails = mockOrderDetails;
const { order, session } = orderDetails;
const totalAmount = order.totalAmount / 100;
const baseAmount = order.baseAmount / 100;
const processingFee = order.processingFee / 100;
const effectiveStatus = session.paymentStatus === 'paid' ? 'paid' : order.status;
const statusDisplay = getStatusDisplay(effectiveStatus);
return (
<>
{/* Print-specific styles */}
<style>{`
@media print {
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
.no-print { display: none !important; }
.print-page-break {
page-break-after: always !important;
break-after: page !important;
}
@page {
margin: 0.5in;
size: letter;
}
}
`}</style>
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 flex items-center justify-center p-4 sm:p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-full sm:max-w-4xl lg:max-w-5xl w-full print-receipt"
>
{/* Demo Banner */}
<div className="bg-yellow-100 border-l-4 border-yellow-500 p-4 mb-4 rounded-lg no-print">
<p className="text-yellow-800 font-medium">
🎭 <strong>DEMO MODE</strong> - This is a mock order for visual comparison purposes
</p>
</div>
{/* Receipt Container */}
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden print:rounded-none print:shadow-none print-page-break">
{/* Header with Logo */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-gradient-to-br from-cyan-500 via-blue-500 to-indigo-600 p-8 text-center"
>
<img
src="/puffinOffset.webp"
alt="Puffin Offset"
className="h-24 mx-auto mb-4 print-logo"
/>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">
Order Confirmed
</h1>
<p className="text-cyan-50 text-lg">
Thank you for your carbon offset purchase
</p>
</motion.div>
{/* Success Badge */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="flex justify-center -mt-8 mb-6 no-print"
>
<div className="bg-green-500 text-white rounded-full p-6 shadow-xl border-4 border-white">
<svg className="w-12 h-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
</div>
</motion.div>
{/* Order Details Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="px-8 py-6"
>
<h2 className="text-2xl font-bold text-slate-800 mb-6 pb-3 border-b-2 border-slate-200">
Order Summary
</h2>
<div className="space-y-1 mb-6">
{/* Carbon Offset - Highlighted */}
<div className="bg-gradient-to-r from-emerald-50 to-teal-50 rounded-xl p-6 mb-4 border-l-4 border-emerald-500">
<div className="flex justify-between items-center">
<div>
<span className="text-sm text-emerald-700 font-medium uppercase tracking-wide">Carbon Offset</span>
<p className="text-3xl font-bold text-emerald-900 mt-1">{order.tons} tons CO</p>
</div>
<div className="text-emerald-600">
<Leaf className="w-16 h-16" />
</div>
</div>
</div>
{/* Pricing Breakdown */}
<div className="bg-slate-50 rounded-lg p-5 my-4 space-y-3">
<div className="flex justify-between items-center">
<span className="text-slate-700">Offset Cost</span>
<span className="text-slate-900 font-semibold">
${formatCurrency(baseAmount)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-700">Processing Fee (3%)</span>
<span className="text-slate-900 font-semibold">
${formatCurrency(processingFee)}
</span>
</div>
<div className="border-t-2 border-slate-300 pt-3 mt-3">
<div className="flex justify-between items-center">
<span className="text-slate-800 font-bold text-lg">Total Paid</span>
<span className="text-blue-600 font-bold text-3xl">
${formatCurrency(totalAmount)}
</span>
</div>
</div>
</div>
</div>
{/* Order Metadata */}
<div className="bg-gradient-to-r from-slate-50 to-blue-50 rounded-lg p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Payment ID</span>
<p className="text-slate-800 font-mono text-xs mt-1 break-all">{order.stripeSessionId}</p>
</div>
{order.wrenOrderId && (
<div>
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Offsetting Order ID</span>
<p className="text-slate-800 font-mono text-xs mt-1 break-all">{order.wrenOrderId}</p>
</div>
)}
<div className={order.wrenOrderId ? '' : 'md:col-start-2'}>
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Status</span>
<p className="mt-2">
<span className={`inline-block px-4 py-1.5 rounded-full text-sm font-bold ${statusDisplay.className}`}>
{statusDisplay.label}
</span>
</p>
</div>
{session.customerEmail && (
<div className="md:col-span-2">
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Email</span>
<p className="text-slate-800 font-medium mt-1">{session.customerEmail}</p>
</div>
)}
<div className="md:col-span-2">
<span className="text-xs uppercase tracking-wide text-slate-500 font-semibold">Date</span>
<p className="text-slate-800 font-medium mt-1">
{new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</p>
</div>
</div>
</div>
</motion.div>
</div>
{/* Portfolio Distribution Chart */}
{order.portfolio?.projects && order.portfolio.projects.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.35 }}
className="mt-6"
>
<div className="bg-white rounded-3xl shadow-2xl overflow-hidden p-8 print:rounded-none print:shadow-none print:border-none print-page-break">
<h2 className="text-2xl font-bold text-slate-800 mb-2 text-center print:text-xl">
Your Carbon Offset Distribution
</h2>
<p className="text-slate-600 text-center mb-8 print:text-sm print:mb-4">
Your {order.tons} tons of CO offsets are distributed across these verified projects:
</p>
<RechartsPortfolioPieChart
projects={order.portfolio.projects}
totalTons={order.tons}
/>
</div>
</motion.div>
)}
{/* Impact Comparisons */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="mt-6"
>
<div className="bg-gradient-to-br from-emerald-600 via-teal-600 to-cyan-600 rounded-3xl p-8 shadow-2xl print:rounded-none print:shadow-none print:bg-white print:border print:border-gray-300 print-page-break">
<CarbonImpactComparison tons={order.tons} variant="success" count={3} />
</div>
</motion.div>
{/* Action Buttons */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="flex flex-col sm:flex-row gap-4 justify-center mt-8 no-print"
>
<button
onClick={() => router.push('/')}
className="px-8 py-4 bg-gradient-to-r from-blue-500 to-cyan-500 text-white rounded-xl hover:from-blue-600 hover:to-cyan-600 transition-all hover:shadow-xl font-bold text-center transform hover:scale-105"
>
Return to Home
</button>
<button
onClick={() => router.push('/calculator')}
className="px-8 py-4 bg-gradient-to-r from-green-500 to-emerald-500 text-white rounded-xl hover:from-green-600 hover:to-emerald-600 transition-all hover:shadow-xl font-bold text-center transform hover:scale-105"
>
Calculate Another Offset
</button>
<button
onClick={() => window.print()}
className="px-8 py-4 bg-white text-slate-700 rounded-xl hover:bg-slate-50 transition-all hover:shadow-xl font-bold border-2 border-slate-300 flex items-center justify-center gap-2 transform hover:scale-105"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Print Receipt
</button>
</motion.div>
{/* Confirmation Email Notice */}
{session.customerEmail && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="text-center mt-6 no-print"
>
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded-lg inline-block">
<p className="text-blue-800 font-medium">
<svg className="w-5 h-5 inline mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
Confirmation email sent to {session.customerEmail}
</p>
</div>
</motion.div>
)}
{/* Footer */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.7 }}
className="text-center text-slate-500 text-sm mt-8 pb-6 no-print"
>
<p>Thank you for making a positive impact on our planet</p>
<p className="mt-2">Questions? Contact us at support@puffinoffset.com</p>
</motion.div>
</motion.div>
</div>
</>
);
}

View File

@ -1,18 +0,0 @@
import type { Metadata } from 'next';
import { ContactClient } from '../../components/ContactClient';
export const metadata: Metadata = {
title: 'Contact',
description: 'Get in touch with Puffin Offset to discuss carbon offsetting solutions for your yacht. Our team is ready to help you start your sustainability journey.',
keywords: ['contact', 'carbon offset inquiry', 'yacht sustainability', 'offset consultation', 'maritime carbon solutions'],
openGraph: {
title: 'Contact Puffin Offset - Maritime Carbon Offsetting Experts',
description: 'Ready to start your sustainability journey? Contact our team today for personalized carbon offsetting solutions.',
type: 'website',
url: 'https://puffinoffset.com/contact',
},
};
export default function ContactPage() {
return <ContactClient />;
}

View File

@ -1,279 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom CSS Variables for Luxury Yacht Club Theme */
:root {
--navy-deep: #0F172A;
--ocean-blue: #1E40AF;
--ocean-light: #3B82F6;
--gold-accent: #F59E0B;
--gold-light: #FCD34D;
--gray-sophisticated: #64748B;
--gray-light: #E2E8F0;
--white-tinted: #F8FAFC;
}
/* Global Styles */
* {
scroll-behavior: smooth;
}
body {
background: linear-gradient(135deg, var(--white-tinted) 0%, #E0F2FE 100%);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Custom Glassmorphism Classes */
.glass-card {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
}
.glass-nav {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
/* Premium Button Styles */
.btn-premium {
background: linear-gradient(135deg, var(--ocean-blue) 0%, var(--ocean-light) 100%);
color: white;
padding: 12px 32px;
border-radius: 12px;
font-weight: 600;
letter-spacing: 0.025em;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 15px 0 rgba(30, 64, 175, 0.3);
position: relative;
overflow: hidden;
}
.btn-premium::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.btn-premium:hover::before {
left: 100%;
}
.btn-premium:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px 0 rgba(30, 64, 175, 0.4);
}
.btn-secondary {
background: linear-gradient(135deg, var(--gold-accent) 0%, var(--gold-light) 100%);
color: var(--navy-deep);
padding: 12px 32px;
border-radius: 12px;
font-weight: 600;
letter-spacing: 0.025em;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 15px 0 rgba(245, 158, 11, 0.3);
position: relative;
overflow: hidden;
}
.btn-secondary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px 0 rgba(245, 158, 11, 0.4);
}
/* Custom Shadow Classes */
.shadow-luxury {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.shadow-premium {
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
/* Wave Pattern Background */
.wave-pattern {
background-image: url("data:image/svg+xml,%3csvg width='100' height='20' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='m0 10c20-10 20 10 40 0s20 10 40 0 20-10 20 0v10h-100z' fill='%23ffffff' fill-opacity='0.03'/%3e%3c/svg%3e");
background-repeat: repeat-x;
background-position: bottom;
}
/* Premium Card Styles */
.luxury-card {
background: linear-gradient(145deg, #ffffff 0%, #f8fafc 100%);
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 20px;
box-shadow: 0 10px 30px -5px rgba(0, 0, 0, 0.1);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.luxury-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--gold-accent), transparent);
opacity: 0;
transition: opacity 0.3s ease;
}
.luxury-card:hover::before {
opacity: 1;
}
.luxury-card:hover {
transform: translateY(-8px);
box-shadow: 0 20px 40px -5px rgba(0, 0, 0, 0.15);
border-color: rgba(30, 64, 175, 0.2);
}
/* Typography Enhancements */
.heading-luxury {
background: linear-gradient(135deg, var(--navy-deep) 0%, var(--ocean-blue) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 700;
letter-spacing: -0.025em;
}
.text-gradient-gold {
background: linear-gradient(135deg, var(--gold-accent) 0%, var(--gold-light) 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-weight: 600;
}
/* Parallax Container */
.parallax-container {
position: relative;
overflow: hidden;
transform-style: preserve-3d;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--gray-light);
}
::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, var(--ocean-blue), var(--ocean-light));
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, var(--navy-deep), var(--ocean-blue));
}
/* Animated Wave Effect */
@keyframes wave {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100px);
}
}
.animate-wave {
animation: wave 15s linear infinite;
}
/* Animated Floating Particles */
@keyframes float {
0% {
transform: translateY(0px) translateX(0px);
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
transform: translateY(-100vh) translateX(50px);
opacity: 0;
}
}
.particle {
position: absolute;
background-color: rgba(255, 255, 255, 0.1);
border-radius: 50%;
animation: float 15s infinite;
}
/* Enhanced gradient overlays */
.gradient-luxury {
background: linear-gradient(135deg,
rgba(15, 23, 42, 0.95) 0%,
rgba(30, 64, 175, 0.85) 50%,
rgba(30, 58, 138, 0.9) 100%);
}
/* Custom Range Slider Styles */
input[type="range"].slider {
-webkit-appearance: none;
width: 100%;
height: 12px;
border-radius: 6px;
background: #e5e7eb;
outline: none;
opacity: 1;
transition: opacity 0.2s;
}
input[type="range"].slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transition: transform 0.2s, box-shadow 0.2s;
}
input[type="range"].slider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: #3b82f6;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
transition: transform 0.2s, box-shadow 0.2s;
border: none;
}
input[type="range"].slider:hover::-webkit-slider-thumb {
transform: scale(1.1);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
}
input[type="range"].slider:hover::-moz-range-thumb {
transform: scale(1.1);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
}

View File

@ -1,18 +0,0 @@
import type { Metadata } from 'next';
import { HowItWorksClient } from '../../components/HowItWorksClient';
export const metadata: Metadata = {
title: 'How It Works',
description: 'Learn how Puffin Offset makes carbon offsetting simple: calculate your yacht\'s emissions, select verified offset projects, and track your environmental impact. Start your sustainability journey today.',
keywords: ['carbon calculator', 'emissions tracking', 'yacht carbon footprint', 'offset projects', 'environmental impact tracking'],
openGraph: {
title: 'How Puffin Offset Works - Simple Carbon Offsetting Process',
description: 'Calculate, offset, and track your yacht\'s carbon footprint in three easy steps with verified projects.',
type: 'website',
url: 'https://puffinoffset.com/how-it-works',
},
};
export default function HowItWorksPage() {
return <HowItWorksClient />;
}

View File

@ -1,106 +0,0 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import Script from 'next/script';
import { RootLayoutClient } from '../components/RootLayoutClient';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: {
default: 'Puffin Offset - Carbon Offsetting for Yachts',
template: '%s | Puffin Offset',
},
description: 'Premium carbon offset calculator and solutions for luxury yachts. Offset your vessel\'s carbon footprint with verified climate projects.',
keywords: ['carbon offset', 'yacht carbon offset', 'luxury yacht sustainability', 'marine carbon calculator', 'yacht emissions', 'climate action'],
authors: [{ name: 'Puffin Offset' }],
creator: 'Puffin Offset',
publisher: 'Puffin Offset',
metadataBase: new URL('https://puffinoffset.com'),
alternates: {
canonical: '/',
},
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://puffinoffset.com',
title: 'Puffin Offset - Carbon Offsetting for Yachts',
description: 'Premium carbon offset calculator and solutions for luxury yachts. Offset your vessel\'s carbon footprint with verified climate projects.',
siteName: 'Puffin Offset',
images: [
{
url: '/puffinOffset.png',
width: 1200,
height: 630,
alt: 'Puffin Offset Logo',
},
],
},
twitter: {
card: 'summary_large_image',
title: 'Puffin Offset - Carbon Offsetting for Yachts',
description: 'Premium carbon offset calculator and solutions for luxury yachts.',
images: ['/puffinOffset.png'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
verification: {
// Add Google Search Console verification here when available
// google: 'your-verification-code',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={inter.className}>
<head>
{/* Preconnect to external domains for performance */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* Favicon and app icons */}
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/puffinOffset.png" />
{/* PWA manifest */}
<link rel="manifest" href="/manifest.json" />
{/* Theme color */}
<meta name="theme-color" content="#1E40AF" />
</head>
<body className="flex flex-col min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 wave-pattern">
{/* Google Analytics - using Next.js Script component for security and performance */}
{process.env.NODE_ENV === 'production' && (
<>
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX');
`}
</Script>
</>
)}
<RootLayoutClient>{children}</RootLayoutClient>
</body>
</html>
);
}

View File

@ -1,6 +0,0 @@
import { ReactNode } from 'react';
// Mobile app layout - renders without header and footer
export default function MobileAppLayout({ children }: { children: ReactNode }) {
return children;
}

View File

@ -1,30 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { MobileCalculator } from '../../src/components/MobileCalculator';
import type { VesselData } from '../../src/types';
// Sample vessel data
const sampleVessel: VesselData = {
imo: "1234567",
vesselName: "Sample Yacht",
type: "Yacht",
length: 50,
width: 9,
estimatedEnginePower: 2250
};
export default function MobileAppPage() {
const router = useRouter();
const handleBack = () => {
router.push('/');
};
return (
<MobileCalculator
vesselData={sampleVessel}
onBack={handleBack}
/>
);
}

View File

@ -1,337 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { Anchor, Waves, Shield, Award, ArrowRight } from 'lucide-react';
import { motion, useScroll, useTransform } from 'framer-motion';
import { useRouter } from 'next/navigation';
export default function Home() {
const router = useRouter();
const [, setMousePosition] = useState({ x: 0, y: 0 });
const heroRef = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({
target: heroRef,
offset: ["start start", "end start"]
});
const y = useTransform(scrollYProgress, [0, 1], ["0%", "30%"]);
const opacity = useTransform(scrollYProgress, [0, 0.8], [1, 0]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
const handleCalculateClick = () => {
router.push('/calculator');
};
const handleLearnMoreClick = () => {
router.push('/about');
};
// Animation variants
const fadeInUp = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.6,
ease: [0.22, 1, 0.36, 1]
}
}
};
const staggerContainer = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2,
delayChildren: 0.3
}
}
};
const scaleOnHover = {
rest: { scale: 1 },
hover: {
scale: 1.05,
transition: {
type: "spring",
stiffness: 400,
damping: 17
}
}
};
return (
<div className="relative" style={{ marginTop: '-110px' }}>
{/* Hero Section */}
<motion.div
ref={heroRef}
className="relative min-h-screen w-full flex items-center justify-center py-20"
style={{ y, opacity }}
>
{/* Hero Content */}
<div className="relative">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, delay: 0.5 }}
className="mb-8"
>
{/* Puffin Logo */}
<motion.img
src="/puffinOffset.webp"
alt="Puffin Offset Logo"
className="h-24 w-auto mx-auto mb-8"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.8, delay: 0.3 }}
/>
<motion.h1
className="text-6xl sm:text-7xl lg:text-8xl font-bold mb-6"
>
<span className="heading-luxury">Luxury Meets</span>
<br />
<span className="text-gradient-gold">Sustainability</span>
</motion.h1>
<motion.p
className="text-xl sm:text-2xl text-slate-600 max-w-4xl mx-auto leading-relaxed mb-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1, delay: 1 }}
>
The world&apos;s most exclusive carbon offsetting platform for superyacht owners and operators
</motion.p>
</motion.div>
<motion.div
className="flex flex-col sm:flex-row gap-6 justify-center items-center"
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 1.3 }}
>
<motion.button
onClick={handleCalculateClick}
className="btn-premium text-lg px-8 py-4"
whileHover={{ scale: 1.05, boxShadow: "0 10px 30px rgba(30, 64, 175, 0.4)" }}
whileTap={{ scale: 0.95 }}
>
Calculate Your Impact
<ArrowRight className="ml-2 inline" size={20} />
</motion.button>
<motion.button
onClick={handleLearnMoreClick}
className="btn-secondary text-lg px-8 py-4"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
Discover More
</motion.button>
</motion.div>
</div>
</div>
</motion.div>
{/* Features Section with Luxury Styling */}
<div className="py-16 relative">
<motion.div
className="text-center mb-16"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
viewport={{ once: true }}
>
<h2 className="text-5xl font-bold heading-luxury mb-6">
Exclusive Maritime Solutions
</h2>
<p className="text-xl text-slate-600 max-w-3xl mx-auto">
Experience the pinnacle of sustainable luxury with our premium carbon offsetting services
</p>
</motion.div>
<motion.div
className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-16"
variants={staggerContainer}
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.2 }}
>
<motion.div
className="luxury-card p-8"
variants={fadeInUp}
whileHover="hover"
>
<motion.div variants={scaleOnHover} className="h-full text-center">
<motion.div
className="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center mx-auto mb-6"
initial={{ rotate: -10, opacity: 0 }}
whileInView={{ rotate: 0, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.2 }}
viewport={{ once: true }}
>
<Waves className="text-white" size={28} />
</motion.div>
<h3 className="text-2xl font-bold heading-luxury mb-4">Flexible Solutions</h3>
<p className="text-slate-600 leading-relaxed">
Tailor your offsetting to match your yachting lifestyle - from single trips to full annual emissions coverage.
</p>
</motion.div>
</motion.div>
<motion.div
className="luxury-card p-8"
variants={fadeInUp}
whileHover="hover"
>
<motion.div variants={scaleOnHover} className="h-full text-center">
<motion.div
className="w-16 h-16 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-full flex items-center justify-center mx-auto mb-6"
initial={{ rotate: 10, opacity: 0 }}
whileInView={{ rotate: 0, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.4 }}
viewport={{ once: true }}
>
<Shield className="text-slate-800" size={28} />
</motion.div>
<h3 className="text-2xl font-bold heading-luxury mb-4">Verified Impact</h3>
<p className="text-slate-600 leading-relaxed">
Science-based projects with transparent reporting ensure your investment creates real environmental change.
</p>
</motion.div>
</motion.div>
<motion.div
className="luxury-card p-8"
variants={fadeInUp}
whileHover="hover"
>
<motion.div variants={scaleOnHover} className="h-full text-center">
<motion.div
className="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-full flex items-center justify-center mx-auto mb-6"
initial={{ rotate: -5, opacity: 0 }}
whileInView={{ rotate: 0, opacity: 1 }}
transition={{ duration: 0.6, delay: 0.6 }}
viewport={{ once: true }}
>
<Award className="text-white" size={28} />
</motion.div>
<h3 className="text-2xl font-bold heading-luxury mb-4">Premium Service</h3>
<p className="text-slate-600 leading-relaxed">
White-glove service designed for discerning yacht owners who demand excellence in sustainability.
</p>
</motion.div>
</motion.div>
</motion.div>
</div>
<motion.div
className="bg-white rounded-xl shadow-lg p-12 mb-16 text-center"
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: "easeOut" }}
viewport={{ once: true, amount: 0.3 }}
>
<div className="max-w-4xl mx-auto">
<div className="flex items-center justify-center space-x-4 mb-6">
<motion.div
initial={{ y: -20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
transition={{
type: "spring",
stiffness: 300,
damping: 15,
delay: 0.2
}}
viewport={{ once: true }}
>
<Anchor className="text-blue-600" size={32} />
</motion.div>
<motion.h2
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
viewport={{ once: true }}
className="text-3xl font-bold text-gray-900"
>
Empower Your Yacht Business with In-House Offsetting
</motion.h2>
</div>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.4 }}
viewport={{ once: true }}
className="text-lg text-gray-600 leading-relaxed text-justify"
>
Our offsetting tool is not only perfect for charter guests and yacht owners, it can also be used by yacht management companies and brokerage firms seeking to integrate sustainability into the entirety of their operations. Use Puffin to offer clients carbon-neutral charter options or manage the environmental footprint of your fleet. Showcase your commitment to eco-conscious luxury while adding value to your services and elevating your brand.
</motion.p>
</div>
</motion.div>
<motion.div
className="text-center bg-white rounded-xl shadow-lg p-12 mb-16"
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, ease: "easeOut" }}
viewport={{ once: true, amount: 0.3 }}
>
<motion.h2
initial={{ opacity: 0, y: -10 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
viewport={{ once: true }}
className="text-3xl font-bold text-gray-900 mb-6"
>
Ready to Make a Difference?
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
viewport={{ once: true }}
className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto"
>
Join the growing community of environmentally conscious yacht owners and operators who are leading the way in maritime sustainability.
</motion.p>
<motion.div
className="flex justify-center space-x-4"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
viewport={{ once: true }}
>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
onClick={handleCalculateClick}
className="bg-blue-600 text-white px-8 py-3 rounded-lg"
>
Calculate Your Impact
</motion.button>
<motion.button
whileHover={{ scale: 1.05, backgroundColor: "rgba(219, 234, 254, 1)" }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
onClick={handleLearnMoreClick}
className="border-2 border-blue-600 text-blue-600 px-8 py-3 rounded-lg"
>
Learn More
</motion.button>
</motion.div>
</motion.div>
</div>
);
}

View File

@ -1,499 +0,0 @@
'use client';
import { useState } from 'react';
import { QRCalculatorData, QRGenerationResponse } from '@/src/types';
import { Download, Copy, Check, Loader2, QrCode } from 'lucide-react';
export default function QRTestPage() {
// Form state
const [calculationType, setCalculationType] = useState<'fuel' | 'distance' | 'custom'>('distance');
const [distance, setDistance] = useState('100');
const [speed, setSpeed] = useState('12');
const [fuelRate, setFuelRate] = useState('100');
const [fuelAmount, setFuelAmount] = useState('500');
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters');
const [customAmount, setCustomAmount] = useState('50');
const [vesselName, setVesselName] = useState('');
const [imo, setImo] = useState('');
// Response state
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [response, setResponse] = useState<QRGenerationResponse | null>(null);
const [copiedUrl, setCopiedUrl] = useState(false);
const [copiedSvg, setCopiedSvg] = useState(false);
// Example presets
const presets = {
distance: {
calculationType: 'distance' as const,
distance: 100,
speed: 12,
fuelRate: 100,
},
fuel: {
calculationType: 'fuel' as const,
fuelAmount: 500,
fuelUnit: 'liters' as const,
},
custom: {
calculationType: 'custom' as const,
customAmount: 100,
},
};
const loadPreset = (preset: keyof typeof presets) => {
const data = presets[preset];
setCalculationType(data.calculationType);
if ('distance' in data) {
setDistance(data.distance.toString());
setSpeed(data.speed!.toString());
setFuelRate(data.fuelRate!.toString());
}
if ('fuelAmount' in data) {
setFuelAmount(data.fuelAmount.toString());
setFuelUnit(data.fuelUnit!);
}
if ('customAmount' in data) {
setCustomAmount(data.customAmount.toString());
}
};
const handleGenerate = async () => {
setIsLoading(true);
setError(null);
setResponse(null);
setCopiedUrl(false);
setCopiedSvg(false);
try {
// Build request data based on calculator type
const requestData: QRCalculatorData = {
calculationType,
timestamp: new Date().toISOString(),
source: 'qr-test-page',
vessel: vesselName || imo ? {
name: vesselName || undefined,
imo: imo || undefined,
} : undefined,
};
if (calculationType === 'distance') {
requestData.distance = parseFloat(distance);
requestData.speed = parseFloat(speed);
requestData.fuelRate = parseFloat(fuelRate);
} else if (calculationType === 'fuel') {
requestData.fuelAmount = parseFloat(fuelAmount);
requestData.fuelUnit = fuelUnit;
} else if (calculationType === 'custom') {
requestData.customAmount = parseFloat(customAmount);
}
// Call API
const res = await fetch('/api/qr-code/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestData),
});
// Check HTTP status first
if (!res.ok) {
let errorMessage = `HTTP ${res.status}: ${res.statusText}`;
try {
const errorData = await res.json();
if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Not JSON, use status text
}
throw new Error(errorMessage);
}
const result = await res.json();
if (!result.success) {
throw new Error(result.error || 'Failed to generate QR code');
}
setResponse(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
} finally {
setIsLoading(false);
}
};
const copyToClipboard = async (text: string, type: 'url' | 'svg') => {
try {
await navigator.clipboard.writeText(text);
if (type === 'url') {
setCopiedUrl(true);
setTimeout(() => setCopiedUrl(false), 2000);
} else {
setCopiedSvg(true);
setTimeout(() => setCopiedSvg(false), 2000);
}
} catch (err) {
console.error('Failed to copy:', err);
}
};
const downloadQR = (dataURL: string, format: 'png' | 'svg') => {
const link = document.createElement('a');
link.href = format === 'png' ? dataURL : `data:image/svg+xml;base64,${btoa(response!.qrCodeSVG)}`;
link.download = `qr-code-${calculationType}-${Date.now()}.${format}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="min-h-screen bg-gradient-to-b from-blue-50 to-green-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<div className="flex items-center justify-center mb-4">
<QrCode className="w-12 h-12 text-blue-600 mr-3" />
<h1 className="text-4xl font-bold text-gray-900">QR Code API Test Page</h1>
</div>
<p className="text-lg text-gray-600">
Test the QR code generation API for the carbon calculator
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Input Form */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Generator Configuration</h2>
{/* Example Presets */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Quick Presets
</label>
<div className="flex flex-wrap gap-2">
<button
onClick={() => loadPreset('distance')}
className="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-colors text-sm font-medium"
>
Distance Example
</button>
<button
onClick={() => loadPreset('fuel')}
className="px-4 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 transition-colors text-sm font-medium"
>
Fuel Example
</button>
<button
onClick={() => loadPreset('custom')}
className="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 transition-colors text-sm font-medium"
>
Custom Example
</button>
</div>
</div>
{/* Calculator Type */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Calculator Type
</label>
<select
value={calculationType}
onChange={(e) => setCalculationType(e.target.value as any)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="distance">Distance-based</option>
<option value="fuel">Fuel-based</option>
<option value="custom">Custom Amount</option>
</select>
</div>
{/* Conditional Fields */}
{calculationType === 'distance' && (
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Distance (nautical miles)
</label>
<input
type="number"
value={distance}
onChange={(e) => setDistance(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Speed (knots)
</label>
<input
type="number"
value={speed}
onChange={(e) => setSpeed(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Fuel Rate (liters/hour)
</label>
<input
type="number"
value={fuelRate}
onChange={(e) => setFuelRate(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
)}
{calculationType === 'fuel' && (
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Fuel Amount
</label>
<input
type="number"
value={fuelAmount}
onChange={(e) => setFuelAmount(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Fuel Unit
</label>
<select
value={fuelUnit}
onChange={(e) => setFuelUnit(e.target.value as any)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="liters">Liters</option>
<option value="gallons">Gallons</option>
</select>
</div>
</div>
)}
{calculationType === 'custom' && (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Custom Amount (USD)
</label>
<input
type="number"
value={customAmount}
onChange={(e) => setCustomAmount(e.target.value)}
placeholder="e.g., 100"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<p className="mt-1 text-xs text-gray-500">
Monetary amount to spend on carbon offsets (calculator will convert to CO)
</p>
</div>
)}
{/* Vessel Information - Optional Metadata */}
<details className="mb-6">
<summary className="cursor-pointer text-sm font-medium text-gray-500 hover:text-gray-700 mb-3 flex items-center">
<span>Optional: Vessel Information (metadata only - not used in calculations)</span>
</summary>
<div className="mt-3 p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-xs text-gray-600 mb-3 italic">
Note: Vessel name and IMO are stored as metadata only. They are not used in carbon calculations.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Vessel Name
</label>
<input
type="text"
value={vesselName}
onChange={(e) => setVesselName(e.target.value)}
placeholder="e.g., My Yacht (optional)"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
IMO Number
</label>
<input
type="text"
value={imo}
onChange={(e) => setImo(e.target.value)}
placeholder="e.g., 1234567 (optional)"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
/>
</div>
</div>
</div>
</details>
{/* Generate Button */}
<button
onClick={handleGenerate}
disabled={isLoading}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isLoading ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Generating...
</>
) : (
'Generate QR Code'
)}
</button>
{/* Error Display */}
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800 text-sm font-medium">Error: {error}</p>
</div>
)}
</div>
{/* Right Column - Results */}
<div className="bg-white rounded-lg shadow-lg p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Generated QR Code</h2>
{!response && !isLoading && (
<div className="flex flex-col items-center justify-center h-96 text-gray-400">
<QrCode className="w-24 h-24 mb-4" />
<p className="text-lg">No QR code generated yet</p>
<p className="text-sm mt-2">Fill the form and click Generate</p>
</div>
)}
{isLoading && (
<div className="flex flex-col items-center justify-center h-96">
<Loader2 className="w-16 h-16 text-blue-600 animate-spin mb-4" />
<p className="text-gray-600">Generating QR code...</p>
</div>
)}
{response && (
<div className="space-y-6">
{/* QR Code Display */}
<div className="flex justify-center p-8 bg-gray-50 rounded-lg">
<img
src={response.qrCodeDataURL}
alt="Generated QR Code"
className="max-w-full h-auto"
/>
</div>
{/* Download Buttons */}
<div className="flex gap-3">
<button
onClick={() => downloadQR(response.qrCodeDataURL, 'png')}
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium flex items-center justify-center"
>
<Download className="w-4 h-4 mr-2" />
Download PNG
</button>
<button
onClick={() => downloadQR(response.qrCodeDataURL, 'svg')}
className="flex-1 py-2 px-4 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium flex items-center justify-center"
>
<Download className="w-4 h-4 mr-2" />
Download SVG
</button>
</div>
{/* URL Display */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Generated URL
</label>
<div className="flex gap-2">
<input
type="text"
value={response.url}
readOnly
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-sm font-mono"
/>
<button
onClick={() => copyToClipboard(response.url, 'url')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center"
>
{copiedUrl ? (
<>
<Check className="w-4 h-4 mr-1" />
Copied!
</>
) : (
<>
<Copy className="w-4 h-4 mr-1" />
Copy
</>
)}
</button>
</div>
</div>
{/* SVG Code */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
SVG Code
</label>
<div className="relative">
<textarea
value={response.qrCodeSVG}
readOnly
rows={6}
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-xs font-mono resize-none"
/>
<button
onClick={() => copyToClipboard(response.qrCodeSVG, 'svg')}
className="absolute top-2 right-2 px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors text-xs flex items-center"
>
{copiedSvg ? (
<>
<Check className="w-3 h-3 mr-1" />
Copied!
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
Copy
</>
)}
</button>
</div>
</div>
{/* Metadata */}
<div className="p-4 bg-gray-50 rounded-lg text-sm">
<h3 className="font-semibold text-gray-900 mb-2">Metadata</h3>
<div className="space-y-1 text-gray-600">
<p><span className="font-medium">Expires:</span> {new Date(response.expiresAt).toLocaleString()}</p>
<p><span className="font-medium">Type:</span> {calculationType}</p>
</div>
</div>
{/* Test Link */}
<a
href={response.url}
target="_blank"
rel="noopener noreferrer"
className="block w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-semibold text-center"
>
Test QR Code Link
</a>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,17 +0,0 @@
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: [
'/api/',
'/checkout/success',
'/checkout/cancel',
'/mobile-app',
],
},
sitemap: 'https://puffinoffset.com/sitemap.xml',
};
}

View File

@ -1,21 +0,0 @@
import { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://puffinoffset.com';
// All static routes
const routes = [
'',
'/about',
'/how-it-works',
'/contact',
'/calculator',
].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: route === '' ? 1 : route === '/calculator' ? 0.9 : 0.8,
}));
return routes;
}

View File

@ -1,159 +0,0 @@
'use client';
import { Heart, Leaf, Scale, FileCheck, Handshake, Rocket } from 'lucide-react';
import { motion } from 'framer-motion';
import { useRouter } from 'next/navigation';
export function AboutClient() {
const router = useRouter();
const handleStartOffsetting = () => {
router.push('/calculator');
};
return (
<div className="max-w-4xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">About Puffin Offset</h1>
<p className="text-xl text-gray-600">
Leading the way in maritime carbon offsetting solutions
</p>
</div>
<div className="prose prose-lg text-gray-600 mb-12">
<p className="text-justify">
Puffin Offset was founded with a clear mission: to make carbon offsetting accessible and effective for the maritime industry. We understand the unique challenges faced by yacht owners and operators in reducing their environmental impact while maintaining the highest standards of luxury and service.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-16">
<motion.div
className="luxury-card p-8"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
viewport={{ once: true }}
whileHover={{ scale: 1.02 }}
>
<div className="flex items-center space-x-4 mb-6">
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-pink-500 rounded-full flex items-center justify-center">
<Heart className="text-white" size={24} />
</div>
<h2 className="text-2xl font-bold heading-luxury">Our Mission</h2>
</div>
<p className="text-slate-600 leading-relaxed text-justify">
To empower the maritime industry with effective, transparent, and accessible carbon offsetting solutions that make a real difference in the fight against climate change.
</p>
</motion.div>
<motion.div
className="luxury-card p-8"
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
viewport={{ once: true }}
whileHover={{ scale: 1.02 }}
>
<div className="flex items-center space-x-4 mb-6">
<div className="w-12 h-12 bg-gradient-to-br from-green-500 to-emerald-500 rounded-full flex items-center justify-center">
<Leaf className="text-white" size={24} />
</div>
<h2 className="text-2xl font-bold heading-luxury">Our Impact</h2>
</div>
<p className="text-slate-600 leading-relaxed text-justify">
Through our partnerships with verified carbon offset projects, we are able to help maritime businesses offset thousands of tons of CO emissions and support sustainable development worldwide.
</p>
</motion.div>
</div>
<motion.div
className="luxury-card p-10 mb-16"
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.3 }}
viewport={{ once: true }}
>
<motion.h2
className="text-3xl font-bold heading-luxury mb-8 text-center"
initial={{ opacity: 0, y: -10 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.5 }}
viewport={{ once: true }}
>
Our Values
</motion.h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<motion.div
className="text-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.6 }}
viewport={{ once: true }}
whileHover={{ scale: 1.05 }}
>
<div className="w-16 h-16 bg-gradient-to-br from-blue-500 to-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Scale className="text-white" size={28} />
</div>
<h3 className="font-bold text-lg heading-luxury mb-3">Transparency</h3>
<p className="text-slate-600 leading-relaxed">Clear, honest reporting on the impact of every offset.</p>
</motion.div>
<motion.div
className="text-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.7 }}
viewport={{ once: true }}
whileHover={{ scale: 1.05 }}
>
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
<FileCheck className="text-white" size={28} />
</div>
<h3 className="font-bold text-lg heading-luxury mb-3">Quality</h3>
<p className="text-slate-600 leading-relaxed">Only the highest standard of verified offset projects.</p>
</motion.div>
<motion.div
className="text-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.8 }}
viewport={{ once: true }}
whileHover={{ scale: 1.05 }}
>
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-purple-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Handshake className="text-white" size={28} />
</div>
<h3 className="font-bold text-lg heading-luxury mb-3">Partnership</h3>
<p className="text-slate-600 leading-relaxed">Working together for a sustainable future.</p>
</motion.div>
<motion.div
className="text-center"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.9 }}
viewport={{ once: true }}
whileHover={{ scale: 1.05 }}
>
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-orange-600 rounded-full flex items-center justify-center mx-auto mb-4">
<Rocket className="text-white" size={28} />
</div>
<h3 className="font-bold text-lg heading-luxury mb-3">Future Proof</h3>
<p className="text-slate-600 leading-relaxed">Constantly improving our service and offsetting products.</p>
</motion.div>
</div>
</motion.div>
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Ready to Make a Difference?</h2>
<button
onClick={handleStartOffsetting}
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Start Offsetting Today
</button>
</div>
</div>
);
}

View File

@ -1,79 +0,0 @@
'use client';
import { useState } from 'react';
import { TripCalculator } from '../src/components/TripCalculator';
import { QRCalculatorLoader } from '../src/components/QRCalculatorLoader';
import { OffsetOrder } from '../src/components/OffsetOrder';
import { useHasQRData } from '../src/hooks/useQRDecoder';
import type { VesselData } from '../src/types';
// Sample vessel data (same as in old App.tsx)
const sampleVessel: VesselData = {
imo: "1234567",
vesselName: "Sample Yacht",
type: "Yacht",
length: 50,
width: 9,
estimatedEnginePower: 2250
};
export function CalculatorClient() {
const hasQRData = useHasQRData(); // Check if URL contains QR code data
const [showOffsetOrder, setShowOffsetOrder] = useState(false);
const [offsetTons, setOffsetTons] = useState(0);
const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>();
const handleOffsetClick = (tons: number, monetaryAmount?: number) => {
setOffsetTons(tons);
setMonetaryAmount(monetaryAmount);
setShowOffsetOrder(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleBackFromOffset = () => {
setShowOffsetOrder(false);
setOffsetTons(0);
setMonetaryAmount(undefined);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
if (showOffsetOrder) {
return (
<div className="flex justify-center px-4 sm:px-0">
<OffsetOrder
tons={offsetTons}
monetaryAmount={monetaryAmount}
onBack={handleBackFromOffset}
calculatorType="trip"
/>
</div>
);
}
return (
<div className="flex flex-col items-center">
<div className="text-center mb-12 max-w-4xl">
<h1 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-4">
Calculate & Offset Your Yacht&apos;s Carbon Footprint
</h1>
<p className="text-base sm:text-lg text-gray-600">
Use the calculator below to estimate your carbon footprint and explore offsetting options through our verified projects.
</p>
</div>
<div className="flex flex-col items-center w-full max-w-4xl space-y-8">
{hasQRData ? (
<QRCalculatorLoader
vesselData={sampleVessel}
onOffsetClick={handleOffsetClick}
/>
) : (
<TripCalculator
vesselData={sampleVessel}
onOffsetClick={handleOffsetClick}
/>
)}
</div>
</div>
);
}

View File

@ -1,219 +0,0 @@
'use client';
import React, { useState } from 'react';
import { Mail, Phone, Loader2 } from 'lucide-react';
import { validateEmail, sendContactFormEmail } from '../src/utils/email';
import { analytics } from '../src/utils/analytics';
export function ContactClient() {
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
company: '',
message: ''
});
const [submitted, setSubmitted] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSending(true);
setError(null);
try {
// Validate email
if (!validateEmail(formData.email)) {
throw new Error('Please enter a valid email address');
}
// Send via SMTP backend
await sendContactFormEmail(formData, 'contact');
setSubmitted(true);
analytics.event('contact', 'form_submitted');
// Reset form after delay
setTimeout(() => {
setFormData({
name: '',
email: '',
phone: '',
company: '',
message: ''
});
setSubmitted(false);
}, 3000);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send message. Please try again.');
analytics.error(err as Error, 'Contact form submission failed');
} finally {
setSending(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<div className="max-w-4xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">Contact Us</h1>
<p className="text-xl text-gray-600">
Ready to start your sustainability journey? Get in touch with our team today.
</p>
</div>
<div className="bg-white rounded-xl shadow-lg p-8 sm:p-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 sm:gap-12">
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Get in Touch</h2>
<p className="text-gray-600 mb-8 text-justify">
Have questions about our carbon offsetting solutions? Our team is here to help you make a difference in maritime sustainability.
</p>
</div>
<div className="space-y-6">
<div className="flex items-center space-x-4">
<Mail className="text-blue-600" size={24} />
<div>
<h3 className="font-semibold text-gray-900">Email Us</h3>
<a
href="mailto:info@puffinoffset.com"
className="text-blue-600 hover:text-blue-700"
>
info@puffinoffset.com
</a>
</div>
</div>
<div className="flex items-center space-x-4">
<Phone className="text-blue-600" size={24} />
<div>
<h3 className="font-semibold text-gray-900">Call Us</h3>
<a
href="tel:+33671187253"
className="text-blue-600 hover:text-blue-700"
>
+33 6 71 18 72 53
</a>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{submitted && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-700">
Thank you for your message. Your email client will open shortly.
</p>
</div>
)}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-700">{error}</p>
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
Phone
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="+1 234 567 8900"
/>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-1">
Company
</label>
<input
type="text"
id="company"
name="company"
value={formData.company}
onChange={handleChange}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
Message *
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows={4}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
></textarea>
</div>
<button
type="submit"
disabled={sending}
className={`w-full flex items-center justify-center bg-blue-600 text-white py-3 rounded-lg transition-colors ${
sending ? 'opacity-50 cursor-not-allowed' : 'hover:bg-blue-700'
}`}
>
{sending ? (
<>
<Loader2 className="animate-spin mr-2" size={20} />
Preparing Email...
</>
) : (
'Send Message'
)}
</button>
<p className="text-sm text-gray-500 text-center">
* Required fields
</p>
</form>
</div>
</div>
</div>
);
}

View File

@ -1,80 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import Image from 'next/image';
export function Footer() {
return (
<footer className="bg-gradient-to-r from-slate-900 via-blue-900 to-slate-900 mt-16 relative overflow-hidden">
<div className="absolute inset-0 bg-[url('data:image/svg+xml,%3csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3e%3cg fill='none' fill-rule='evenodd'%3e%3cg fill='%23ffffff' fill-opacity='0.03'%3e%3cpath d='m36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3e%3c/g%3e%3c/g%3e%3c/svg%3e')] opacity-20"></div>
<div className="relative max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
>
<div className="flex items-center space-x-3 mb-4">
<Image
src="/puffinOffset.webp"
alt="Puffin Offset Logo"
width={32}
height={32}
className="h-8 w-auto"
/>
<h3 className="text-xl font-bold text-white">Puffin Offset</h3>
</div>
<p className="text-slate-300 leading-relaxed">
The world&apos;s most exclusive carbon offsetting platform for superyacht owners and operators.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
viewport={{ once: true }}
className="text-center md:text-left"
>
<h4 className="text-lg font-semibold text-white mb-4">Services</h4>
<ul className="space-y-2 text-slate-300">
<li className="hover:text-yellow-400 transition-colors cursor-pointer">Carbon Calculator</li>
<li className="hover:text-yellow-400 transition-colors cursor-pointer">Offset Portfolio</li>
<li className="hover:text-yellow-400 transition-colors cursor-pointer">Fleet Management</li>
<li className="hover:text-yellow-400 transition-colors cursor-pointer">Custom Solutions</li>
</ul>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
viewport={{ once: true }}
className="text-center md:text-left"
>
<h4 className="text-lg font-semibold text-white mb-4">Sustainability Partners</h4>
<p className="text-slate-300 mb-4">
Powered by verified carbon offset projects through Wren Climate
</p>
<div className="text-xs text-slate-400">
All projects are verified to international standards
</div>
</motion.div>
</div>
<motion.div
className="border-t border-slate-700 pt-8 text-center"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.6 }}
viewport={{ once: true }}
>
<p className="text-slate-400">
© 2024 Puffin Offset. Luxury meets sustainability.
</p>
</motion.div>
</div>
</footer>
);
}

View File

@ -1,147 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { Menu, X } from 'lucide-react';
import { motion } from 'framer-motion';
import { usePathname, useRouter } from 'next/navigation';
import Image from 'next/image';
export function Header() {
const pathname = usePathname();
const router = useRouter();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [showHeader, setShowHeader] = useState(true);
useEffect(() => {
let lastScroll = 0;
// Hide header on scroll down, show on scroll up
const handleScroll = () => {
const currentScrollY = window.scrollY;
// Always show header at the top of the page
if (currentScrollY < 10) {
setShowHeader(true);
} else if (currentScrollY > lastScroll) {
// Scrolling down
setShowHeader(false);
} else {
// Scrolling up
setShowHeader(true);
}
lastScroll = currentScrollY;
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []); // Empty dependency array - only set up once
const handleNavigate = (path: string) => {
setMobileMenuOpen(false);
router.push(path);
};
const navItems = [
{ path: '/calculator', label: 'Calculator' },
{ path: '/how-it-works', label: 'How it Works' },
{ path: '/about', label: 'About' },
{ path: '/contact', label: 'Contact' },
];
return (
<header
className="glass-nav shadow-luxury z-50 fixed top-0 left-0 right-0 transition-transform duration-300 ease-in-out"
style={{
transform: showHeader ? 'translate3d(0,0,0)' : 'translate3d(0,-100%,0)',
WebkitTransform: showHeader ? 'translate3d(0,0,0)' : 'translate3d(0,-100%,0)',
WebkitBackfaceVisibility: 'hidden',
backfaceVisibility: 'hidden'
}}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<motion.div
className="flex items-center space-x-3 cursor-pointer group"
onClick={() => handleNavigate('/')}
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<motion.div
className="relative h-10 w-10"
initial={{ opacity: 0, rotate: -10 }}
animate={{ opacity: 1, rotate: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<Image
src="/puffinOffset.webp"
alt="Puffin Offset Logo"
width={40}
height={40}
className="transition-transform duration-300 group-hover:scale-110"
/>
</motion.div>
<motion.h1
className="text-xl font-bold heading-luxury"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
Puffin Offset
</motion.h1>
</motion.div>
{/* Mobile menu button */}
<button
className="sm:hidden p-2 rounded-md text-gray-600 hover:text-gray-900"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
{/* Desktop navigation */}
<nav className="hidden sm:flex space-x-2">
{navItems.map((item, index) => (
<motion.button
key={item.path}
onClick={() => handleNavigate(item.path)}
className={`px-4 py-2 rounded-xl font-medium transition-all duration-300 ${
pathname === item.path
? 'bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg'
: 'text-slate-600 hover:text-slate-900 hover:bg-white/60'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.6 + index * 0.1 }}
>
{item.label}
</motion.button>
))}
</nav>
</div>
{/* Mobile navigation */}
{mobileMenuOpen && (
<nav className="sm:hidden mt-4 pb-2 space-y-2">
{navItems.map((item) => (
<button
key={item.path}
onClick={() => handleNavigate(item.path)}
className={`block w-full text-left px-4 py-2 rounded-lg ${
pathname === item.path
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
{item.label}
</button>
))}
</nav>
)}
</div>
</header>
);
}

View File

@ -1,87 +0,0 @@
'use client';
import { Leaf, Anchor, Calculator, Globe, BarChart} from 'lucide-react';
import { useRouter } from 'next/navigation';
export function HowItWorksClient() {
const router = useRouter();
const handleOffsetClick = () => {
router.push('/calculator');
};
return (
<div className="max-w-4xl mx-auto pb-12 px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<div className="flex justify-center items-center space-x-3 mb-6">
<Leaf className="text-green-500" size={32} />
<Anchor className="text-blue-500" size={32} />
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-4">How It Works</h1>
</div>
<div className="space-y-12">
<section className="bg-white rounded-lg shadow-lg p-8">
<div className="flex items-center space-x-4 mb-6">
<Calculator className="text-blue-500" size={28} />
<h2 className="text-2xl font-bold text-gray-900">1. Calculate Your Impact</h2>
</div>
<div className="prose prose-lg text-gray-600">
<p className="text-justify mb-4">
Enter your vessel&apos;s fuel usage or nautical miles travelled to calculate how many tons of CO2 have been produced.
Choose between calculating emissions for specific trips or annual operations to get a precise understanding of your environmental impact.
</p>
</div>
</section>
<section className="bg-white rounded-lg shadow-lg p-8">
<div className="flex items-center space-x-4 mb-6">
<Globe className="text-green-500" size={28} />
<h2 className="text-2xl font-bold text-gray-900">2. Select Your Offset Project</h2>
</div>
<div className="prose prose-lg text-gray-600">
<p className="text-justify mb-4">
Choose the percentage of CO2 production you would like to offset via our curated carbon offset portfolio. Each project is thoroughly vetted and monitored to ensure your contribution creates real, measurable impact in reducing global carbon emissions. Alternatively, contact us direct to design a bespoke offsetting product specifically tailored to your needs, including tax-deductible offsets for US customers.
</p>
</div>
</section>
<section className="bg-white rounded-lg shadow-lg p-8">
<div className="flex items-center space-x-4 mb-6">
<BarChart className="text-blue-500" size={28} />
<h2 className="text-2xl font-bold text-gray-900">3. Track Your Impact</h2>
</div>
<div className="prose prose-lg text-gray-600">
<p className="text-justify mb-4">
Sign up to stay connected to your environmental impact through:
</p>
<ul className="list-disc pl-6 space-y-2">
<li>Regular project updates and progress reports</li>
<li>Detailed emissions reduction tracking</li>
<li>Impact certificates for your offset contributions</li>
<li>Transparent project performance metrics</li>
</ul>
<p className="text-justify mt-4">
Monitor your contribution to global sustainability efforts and share your commitment to environmental stewardship with others in the yachting community.
</p>
</div>
</section>
<section className="bg-gradient-to-r from-blue-50 to-green-50 rounded-lg shadow-lg p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Ready to Make a Difference?</h2>
<div className="prose prose-lg text-gray-600 mb-8">
<p>
Start your carbon offsetting journey today and join the growing community of environmentally conscious yacht owners who are leading the way in maritime sustainability.
</p>
</div>
<button
onClick={handleOffsetClick}
className="bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Offset Your Impact
</button>
</section>
</div>
</div>
);
}

View File

@ -1,50 +0,0 @@
'use client';
import { usePathname } from 'next/navigation';
import { useState, useEffect } from 'react';
import { Header } from './Header';
import { Footer } from './Footer';
import { UpdateNotification } from './UpdateNotification';
import * as swRegistration from '../lib/serviceWorkerRegistration';
export function RootLayoutClient({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAdminRoute = pathname?.startsWith('/admin');
const isCheckoutSuccess = pathname?.startsWith('/checkout/success');
const [updateAvailable, setUpdateAvailable] = useState<ServiceWorkerRegistration | null>(null);
useEffect(() => {
// Register service worker with update detection
swRegistration.register({
onUpdate: (registration) => {
console.log('New version available!');
setUpdateAvailable(registration);
},
onSuccess: () => {
console.log('Service worker registered successfully');
}
});
}, []);
if (isAdminRoute || isCheckoutSuccess) {
// Admin routes and checkout success render without header/footer
return (
<>
{children}
<UpdateNotification registration={updateAvailable} />
</>
);
}
// Regular routes render with header/footer
return (
<>
<Header />
<main className="flex-1 max-w-[1600px] w-full mx-auto pb-8 sm:pb-12 px-4 sm:px-6 lg:px-8 overflow-hidden" style={{ paddingTop: '110px' }}>
{children}
</main>
<Footer />
<UpdateNotification registration={updateAvailable} />
</>
);
}

View File

@ -1,163 +0,0 @@
'use client';
import { useState } from 'react';
import { Info, ChevronDown, ChevronUp, ShieldCheck, Clock, Leaf, TrendingUp } from 'lucide-react';
export function StandardsLegend() {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div className="mt-8 mb-8 bg-gradient-to-br from-blue-50 to-cyan-50 rounded-2xl p-6 border border-blue-200 print:hidden">
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between text-left hover:opacity-80 transition-opacity"
>
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500 rounded-lg">
<Info className="w-5 h-5 text-white" />
</div>
<div>
<h3 className="text-lg font-bold text-slate-800">
Understanding Wren's Carbon Offset Standards
</h3>
<p className="text-sm text-slate-600">
Learn about the different types of climate impact in your portfolio
</p>
</div>
</div>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-slate-600 flex-shrink-0" />
) : (
<ChevronDown className="w-5 h-5 text-slate-600 flex-shrink-0" />
)}
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="mt-6 space-y-6 animate-in slide-in-from-top-2 duration-300">
{/* Intro */}
<div className="bg-white/70 rounded-xl p-4 border border-blue-100">
<p className="text-sm text-slate-700 leading-relaxed">
Wren maximizes climate impact by combining multiple layers of solutions: <strong>Certified offsets</strong> (verified carbon removal/reduction), <strong>In-progress offsets</strong> (long-term removal projects), <strong>Additional contributions</strong> (policy & conservation work), and <strong>Investments</strong> (scaling climate companies). This hybrid approach delivers the greatest impact per dollar.
</p>
</div>
{/* Standard Categories */}
<div className="grid md:grid-cols-2 gap-4">
{/* Certified Offsets */}
<div className="bg-white rounded-xl p-5 border-2 border-emerald-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start gap-3 mb-3">
<div className="p-2 bg-emerald-100 rounded-lg">
<ShieldCheck className="w-5 h-5 text-emerald-700" />
</div>
<div>
<h4 className="font-bold text-slate-800 text-base">Certified Offsets</h4>
<p className="text-xs text-emerald-700 font-medium">Standard 2025+ Compliant</p>
</div>
</div>
<p className="text-sm text-slate-700 mb-3 leading-relaxed">
Quantified COe that has already been prevented or removed from the atmosphere. These offsets follow strict rules and are verified by independent auditors.
</p>
<div className="bg-emerald-50 rounded-lg p-3 space-y-1">
<p className="text-xs text-slate-700"><strong></strong> Peer-reviewed measurement methodology</p>
<p className="text-xs text-slate-700"><strong></strong> ISO-accredited third-party verification</p>
<p className="text-xs text-slate-700"><strong></strong> Tracked on carbon credit registry</p>
<p className="text-xs text-slate-700"><strong></strong> Credits issued within 12 months</p>
</div>
</div>
{/* In-Progress Offsets */}
<div className="bg-white rounded-xl p-5 border-2 border-yellow-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start gap-3 mb-3">
<div className="p-2 bg-yellow-100 rounded-lg">
<Clock className="w-5 h-5 text-yellow-700" />
</div>
<div>
<h4 className="font-bold text-slate-800 text-base">In-Progress Offsets</h4>
<p className="text-xs text-yellow-700 font-medium">Long-term carbon removal</p>
</div>
</div>
<p className="text-sm text-slate-700 mb-3 leading-relaxed">
Activities currently removing COe from the atmosphere using proven methods. Credits will be issued once the work is complete and verified (within 5 years).
</p>
<div className="bg-yellow-50 rounded-lg p-3 space-y-1">
<p className="text-xs text-slate-700"><strong></strong> Peer-reviewed methodology</p>
<p className="text-xs text-slate-700"><strong></strong> Third-party verification pending</p>
<p className="text-xs text-slate-700"><strong></strong> Credits issued within 5 years</p>
<p className="text-xs text-slate-700"><strong></strong> Example: Enhanced mineral weathering</p>
</div>
</div>
{/* Additional Contributions */}
<div className="bg-white rounded-xl p-5 border-2 border-cyan-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start gap-3 mb-3">
<div className="p-2 bg-cyan-100 rounded-lg">
<Leaf className="w-5 h-5 text-cyan-700" />
</div>
<div>
<h4 className="font-bold text-slate-800 text-base">Additional Contributions</h4>
<p className="text-xs text-cyan-700 font-medium">Systems-change initiatives</p>
</div>
</div>
<p className="text-sm text-slate-700 mb-3 leading-relaxed">
Funding for climate policy, conservation, and initiatives that are difficult to quantify but essential for creating systemic change toward a carbon-neutral world.
</p>
<div className="bg-cyan-50 rounded-lg p-3 space-y-1">
<p className="text-xs text-slate-700"><strong></strong> Peer-reviewed science (proof of concept)</p>
<p className="text-xs text-slate-700"><strong></strong> Clear theory of change</p>
<p className="text-xs text-slate-700"><strong></strong> Transparent monitoring & evaluation</p>
<p className="text-xs text-slate-700"><strong></strong> Example: Rainforest protection technology</p>
</div>
</div>
{/* Investments */}
<div className="bg-white rounded-xl p-5 border-2 border-purple-200 shadow-sm hover:shadow-md transition-shadow">
<div className="flex items-start gap-3 mb-3">
<div className="p-2 bg-purple-100 rounded-lg">
<TrendingUp className="w-5 h-5 text-purple-700" />
</div>
<div>
<h4 className="font-bold text-slate-800 text-base">Investments</h4>
<p className="text-xs text-purple-700 font-medium">Scaling climate solutions</p>
</div>
</div>
<p className="text-sm text-slate-700 mb-3 leading-relaxed">
Capital investments in climate companies to scale operations. Returns amplify climate impact rather than profit. Not counted in tonnes offset.
</p>
<div className="bg-purple-50 rounded-lg p-3 space-y-1">
<p className="text-xs text-slate-700"><strong></strong> Clear theory of change</p>
<p className="text-xs text-slate-700"><strong></strong> Financial additionality</p>
<p className="text-xs text-slate-700"><strong></strong> High scalability potential</p>
<p className="text-xs text-slate-700"><strong></strong> Example: Pacific Biochar expansion</p>
</div>
</div>
</div>
{/* Why This Approach */}
<div className="bg-gradient-to-r from-blue-600 to-cyan-600 rounded-xl p-5 text-white">
<h4 className="font-bold text-lg mb-2">Why Wren Uses This Hybrid Approach</h4>
<p className="text-sm text-blue-50 leading-relaxed">
Addressing the climate crisis requires more than just measurable carbon offsets. By combining <strong>verified removals</strong>, <strong>emerging technologies</strong>, <strong>policy change</strong>, and <strong>strategic investments</strong>, Wren maximizes climate impact per dollar while supporting the systemic changes needed for a sustainable future.
</p>
</div>
{/* Learn More */}
<div className="text-center">
<a
href="https://www.wren.co/certification"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700 hover:underline"
>
<span>Learn more about Wren's certification standards</span>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
</div>
)}
</div>
);
}

View File

@ -1,72 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
interface UpdateNotificationProps {
registration: ServiceWorkerRegistration | null;
}
export function UpdateNotification({ registration }: UpdateNotificationProps) {
const [showNotification, setShowNotification] = useState(false);
useEffect(() => {
if (registration) {
setShowNotification(true);
}
}, [registration]);
const handleUpdate = () => {
if (registration && registration.waiting) {
// Tell the service worker to skip waiting
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Listen for the controller change and reload
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
};
const handleDismiss = () => {
setShowNotification(false);
};
if (!showNotification) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 max-w-md w-full mx-4">
<div className="rounded-lg p-4 shadow-2xl border border-blue-500/30 backdrop-blur-md bg-slate-900/90">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<RefreshCw className="w-6 h-6 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">
New version available!
</p>
<p className="text-xs text-slate-300 mt-1">
A new version of Puffin Offset is ready. Please update to get the latest features and improvements.
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Update Now
</button>
<button
onClick={handleDismiss}
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
Later
</button>
</div>
</div>
</div>
);
}

View File

@ -1,111 +0,0 @@
'use client';
import { useRouter, usePathname } from 'next/navigation';
import { LayoutDashboard, Package, LogOut } from 'lucide-react';
import { motion } from 'framer-motion';
import Image from 'next/image';
interface NavItem {
label: string;
href: string;
icon: React.ReactNode;
}
export function AdminSidebar() {
const router = useRouter();
const pathname = usePathname();
const navItems: NavItem[] = [
{
label: 'Dashboard',
href: '/admin/dashboard',
icon: <LayoutDashboard size={20} />,
},
{
label: 'Orders',
href: '/admin/orders',
icon: <Package size={20} />,
},
];
const handleLogout = async () => {
try {
await fetch('/api/admin/auth/logout', {
method: 'POST',
});
router.push('/admin/login');
} catch (error) {
console.error('Logout failed:', error);
}
};
return (
<div className="w-64 min-h-screen bg-deep-sea-blue text-off-white flex flex-col fixed left-0 top-0 bottom-0 shadow-2xl">
{/* Logo */}
<div className="p-6 border-b border-off-white/10">
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center space-x-3"
>
<div className="w-10 h-10 bg-white rounded-full flex items-center justify-center shadow-lg p-1">
<Image
src="/puffinOffset.png"
alt="Puffin Offset Logo"
width={32}
height={32}
className="object-contain"
/>
</div>
<div>
<h1 className="font-bold text-lg text-off-white">Puffin Admin</h1>
<p className="text-xs text-off-white/80 font-medium">Management Portal</p>
</div>
</motion.div>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2">
{navItems.map((item, index) => {
const isActive = pathname === item.href;
return (
<motion.button
key={item.href}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
onClick={() => router.push(item.href)}
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-all ${
isActive
? 'bg-maritime-teal text-white shadow-lg font-semibold'
: 'text-off-white/80 hover:bg-maritime-teal/20 hover:text-off-white font-medium'
}`}
>
{item.icon}
<span>{item.label}</span>
</motion.button>
);
})}
</nav>
{/* User Info & Logout */}
<div className="p-4 border-t border-off-white/10 space-y-2">
<div className="px-4 py-2 bg-off-white/5 rounded-lg">
<p className="text-xs text-off-white/70 font-medium">Signed in as</p>
<p className="text-sm font-semibold text-off-white">Administrator</p>
</div>
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={handleLogout}
className="w-full flex items-center space-x-3 px-4 py-3 rounded-lg bg-red-500/20 hover:bg-red-500/30 text-red-200 hover:text-white transition-all font-medium"
>
<LogOut size={20} />
<span>Logout</span>
</motion.button>
</div>
</div>
);
}

View File

@ -1,296 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Download, Loader2 } from 'lucide-react';
import { OrderRecord } from '@/src/types';
interface ExportButtonProps {
currentOrders?: OrderRecord[];
filters?: {
search?: string;
status?: string;
dateFrom?: string;
dateTo?: string;
};
}
export function ExportButton({ currentOrders = [], filters = {} }: ExportButtonProps) {
const [isExporting, setIsExporting] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
// Close dropdown on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowDropdown(false);
};
if (showDropdown) {
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}
}, [showDropdown]);
const formatDate = (dateString: string | undefined) => {
if (!dateString) return '';
return new Date(dateString).toISOString();
};
const formatCurrency = (amount: string | undefined) => {
if (!amount) return '';
return (parseFloat(amount) / 100).toFixed(2);
};
const convertToCSV = (orders: OrderRecord[]): string => {
if (orders.length === 0) return '';
// CSV Headers
const headers = [
'Order ID',
'Status',
'Source',
'Created At',
'Updated At',
'Customer Name',
'Customer Email',
'Customer Phone',
'Business Name',
'Tax ID Type',
'Tax ID Value',
'Base Amount',
'Processing Fee',
'Total Amount',
'Currency',
'Payment Method',
'Stripe Session ID',
'Stripe Payment Intent',
'Stripe Customer ID',
'CO2 Tons',
'Portfolio ID',
'Portfolio Name',
'Wren Order ID',
'Certificate URL',
'Fulfilled At',
'Billing Line 1',
'Billing Line 2',
'Billing City',
'Billing State',
'Billing Postal Code',
'Billing Country',
'Vessel Name',
'IMO Number',
'Vessel Type',
'Vessel Length',
'Departure Port',
'Arrival Port',
'Distance',
'Avg Speed',
'Duration',
'Engine Power',
'Notes',
];
// CSV Rows
const rows = orders.map((order) => [
order.orderId,
order.status,
order.source || '',
formatDate(order.CreatedAt),
formatDate(order.UpdatedAt),
order.customerName,
order.customerEmail,
order.customerPhone || '',
order.businessName || '',
order.taxIdType || '',
order.taxIdValue || '',
formatCurrency(order.baseAmount),
formatCurrency(order.processingFee),
formatCurrency(order.totalAmount),
order.currency,
order.paymentMethod || '',
order.stripeSessionId || '',
order.stripePaymentIntent || '',
order.stripeCustomerId || '',
parseFloat(order.co2Tons).toFixed(2),
order.portfolioId,
order.portfolioName || '',
order.wrenOrderId || '',
order.certificateUrl || '',
formatDate(order.fulfilledAt),
order.billingLine1 || '',
order.billingLine2 || '',
order.billingCity || '',
order.billingState || '',
order.billingPostalCode || '',
order.billingCountry || '',
order.vesselName || '',
order.imoNumber || '',
order.vesselType || '',
order.vesselLength || '',
order.departurePort || '',
order.arrivalPort || '',
order.distance || '',
order.avgSpeed || '',
order.duration || '',
order.enginePower || '',
order.notes || '',
]);
// Escape CSV fields (handle commas, quotes, newlines)
const escapeCSVField = (field: string | number) => {
const stringField = String(field);
if (
stringField.includes(',') ||
stringField.includes('"') ||
stringField.includes('\n')
) {
return `"${stringField.replace(/"/g, '""')}"`;
}
return stringField;
};
// Build CSV
const csvContent = [
headers.map(escapeCSVField).join(','),
...rows.map((row) => row.map(escapeCSVField).join(',')),
].join('\n');
return csvContent;
};
const downloadCSV = (csvContent: string, filename: string) => {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
const handleExportCurrent = () => {
setIsExporting(true);
setShowDropdown(false);
try {
const csvContent = convertToCSV(currentOrders);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `orders-current-${timestamp}.csv`;
downloadCSV(csvContent, filename);
} catch (error) {
console.error('Export failed:', error);
alert('Failed to export orders. Please try again.');
} finally {
setTimeout(() => setIsExporting(false), 1000);
}
};
const handleExportAll = async () => {
setIsExporting(true);
setShowDropdown(false);
try {
// Build query params with current filters
const params = new URLSearchParams();
params.append('limit', '10000'); // High limit to get all
params.append('offset', '0');
if (filters.search) params.append('search', filters.search);
if (filters.status) params.append('status', filters.status);
if (filters.dateFrom) params.append('dateFrom', filters.dateFrom);
if (filters.dateTo) params.append('dateTo', filters.dateTo);
// Fetch all orders
const response = await fetch(`/api/admin/orders?${params}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to fetch orders');
}
const allOrders = data.data.list || [];
const csvContent = convertToCSV(allOrders);
const timestamp = new Date().toISOString().split('T')[0];
const filename = `orders-all-${timestamp}.csv`;
downloadCSV(csvContent, filename);
} catch (error) {
console.error('Export failed:', error);
alert('Failed to export all orders. Please try again.');
} finally {
setTimeout(() => setIsExporting(false), 1000);
}
};
return (
<div className="relative inline-block">
<button
type="button"
onClick={() => setShowDropdown(!showDropdown)}
disabled={isExporting}
className="flex items-center space-x-2 px-4 py-2.5 text-sm font-medium text-white bg-deep-sea-blue hover:bg-deep-sea-blue/90 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed shadow-sm"
title="Export orders to CSV"
>
{isExporting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Exporting...</span>
</>
) : (
<>
<Download className="w-4 h-4" />
<span>Export</span>
</>
)}
</button>
{/* Dropdown Menu */}
<AnimatePresence>
{showDropdown && (
<>
{/* Backdrop to close dropdown */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-10"
onClick={() => setShowDropdown(false)}
/>
{/* Dropdown */}
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
className="absolute right-0 mt-2 w-64 bg-white border border-light-gray-border rounded-xl shadow-lg z-20 overflow-hidden"
>
<button
type="button"
onClick={handleExportCurrent}
className="w-full px-4 py-3 text-left text-sm text-deep-sea-blue hover:bg-gray-50 transition-colors border-b border-gray-100"
>
<div className="font-medium">Export Current Page</div>
<div className="text-xs text-deep-sea-blue/60 mt-0.5">
{currentOrders.length} orders
</div>
</button>
<button
type="button"
onClick={handleExportAll}
className="w-full px-4 py-3 text-left text-sm text-deep-sea-blue hover:bg-gray-50 transition-colors"
>
<div className="font-medium">Export All (with filters)</div>
<div className="text-xs text-deep-sea-blue/60 mt-0.5">
All matching orders as CSV
</div>
</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}

View File

@ -1,359 +0,0 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Copy, Check, ExternalLink } from 'lucide-react';
import { useState } from 'react';
import { OrderRecord } from '@/src/types';
interface OrderDetailsModalProps {
order: OrderRecord | null;
isOpen: boolean;
onClose: () => void;
}
export function OrderDetailsModal({ order, isOpen, onClose }: OrderDetailsModalProps) {
const [copiedField, setCopiedField] = useState<string | null>(null);
if (!order) return null;
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedField(fieldName);
setTimeout(() => setCopiedField(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const formatDate = (dateString: string | undefined) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatCurrency = (amount: string | undefined, currency: string = 'USD') => {
if (!amount) return 'N/A';
const numAmount = parseFloat(amount) / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(numAmount);
};
const getStatusBadgeClass = (status: string) => {
switch (status) {
case 'pending':
return 'bg-gradient-to-r from-muted-gold/20 to-orange-400/20 text-muted-gold border border-muted-gold/30';
case 'paid':
return 'bg-gradient-to-r from-maritime-teal/20 to-teal-400/20 text-maritime-teal border border-maritime-teal/30';
case 'fulfilled':
return 'bg-gradient-to-r from-sea-green/20 to-green-400/20 text-sea-green border border-sea-green/30';
case 'cancelled':
return 'bg-gradient-to-r from-red-500/20 to-red-400/20 text-red-600 border border-red-500/30';
default:
return 'bg-gray-100 text-gray-700 border border-gray-300';
}
};
const CopyButton = ({ text, fieldName }: { text: string; fieldName: string }) => (
<button
type="button"
onClick={() => copyToClipboard(text, fieldName)}
className="ml-2 p-1 hover:bg-gray-100 rounded transition-colors"
title="Copy to clipboard"
>
{copiedField === fieldName ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4 text-deep-sea-blue/40" />
)}
</button>
);
const DetailField = ({
label,
value,
copyable = false,
fieldName = '',
}: {
label: string;
value: string | number | undefined | null;
copyable?: boolean;
fieldName?: string;
}) => {
const displayValue = value || 'N/A';
return (
<div className="py-3 border-b border-gray-100 last:border-0">
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">{label}</div>
<div className="flex items-center">
<div className="text-sm text-deep-sea-blue font-medium">{displayValue}</div>
{copyable && value && <CopyButton text={String(value)} fieldName={fieldName} />}
</div>
</div>
);
};
const SectionHeader = ({ title }: { title: string }) => (
<h3 className="text-lg font-bold text-deep-sea-blue mb-4 flex items-center">
<div className="w-1 h-6 bg-deep-sea-blue rounded-full mr-3"></div>
{title}
</h3>
);
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/50 z-40"
/>
{/* Sliding Panel */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
className="fixed right-0 top-0 bottom-0 w-full max-w-2xl bg-white shadow-2xl z-50 overflow-hidden flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 bg-gray-50">
<div>
<h2 className="text-2xl font-bold text-deep-sea-blue">Order Details</h2>
<p className="text-sm text-deep-sea-blue/60 mt-1">
ID: {order.orderId.substring(0, 12)}...
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<X className="w-6 h-6 text-deep-sea-blue" />
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{/* Order Information */}
<section>
<SectionHeader title="Order Information" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Order ID" value={order.orderId} copyable fieldName="orderId" />
<div className="py-3 border-b border-gray-100">
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">Status</div>
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadgeClass(
order.status
)}`}
>
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
</span>
</div>
<DetailField label="Source" value={order.source || 'web'} />
<DetailField label="Created At" value={formatDate(order.CreatedAt)} />
<DetailField label="Last Updated" value={formatDate(order.UpdatedAt)} />
</div>
</section>
{/* Payment Details */}
<section>
<SectionHeader title="Payment Details" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField
label="Total Amount"
value={formatCurrency(order.totalAmount, order.currency)}
/>
<DetailField
label="Base Amount"
value={formatCurrency(order.baseAmount, order.currency)}
/>
<DetailField
label="Processing Fee"
value={formatCurrency(order.processingFee, order.currency)}
/>
<DetailField label="Currency" value={order.currency} />
{order.amountUSD && (
<DetailField label="Amount (USD)" value={formatCurrency(order.amountUSD, 'USD')} />
)}
<DetailField label="Payment Method" value={order.paymentMethod} />
<DetailField
label="Stripe Session ID"
value={order.stripeSessionId}
copyable
fieldName="stripeSessionId"
/>
<DetailField
label="Stripe Payment Intent"
value={order.stripePaymentIntent}
copyable
fieldName="stripePaymentIntent"
/>
<DetailField
label="Stripe Customer ID"
value={order.stripeCustomerId}
copyable
fieldName="stripeCustomerId"
/>
</div>
</section>
{/* Customer Information */}
<section>
<SectionHeader title="Customer Information" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Name" value={order.customerName} />
<DetailField label="Email" value={order.customerEmail} copyable fieldName="email" />
<DetailField label="Phone" value={order.customerPhone} />
{order.businessName && (
<>
<DetailField label="Business Name" value={order.businessName} />
<DetailField label="Tax ID Type" value={order.taxIdType} />
<DetailField
label="Tax ID Value"
value={order.taxIdValue}
copyable
fieldName="taxId"
/>
</>
)}
</div>
</section>
{/* Billing Address */}
<section>
<SectionHeader title="Billing Address" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Address Line 1" value={order.billingLine1} />
<DetailField label="Address Line 2" value={order.billingLine2} />
<DetailField label="City" value={order.billingCity} />
<DetailField label="State/Province" value={order.billingState} />
<DetailField label="Postal Code" value={order.billingPostalCode} />
<DetailField label="Country" value={order.billingCountry} />
</div>
</section>
{/* Carbon Offset Details */}
<section>
<SectionHeader title="Carbon Offset Details" />
<div className="bg-gray-50 rounded-xl p-4">
<div className="py-3 border-b border-gray-100">
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">CO Offset</div>
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-semibold bg-sea-green/20 text-sea-green border border-sea-green/30">
{parseFloat(order.co2Tons).toFixed(2)} tons
</span>
</div>
<DetailField label="Portfolio ID" value={order.portfolioId} />
<DetailField label="Portfolio Name" value={order.portfolioName} />
<DetailField
label="Wren Order ID"
value={order.wrenOrderId}
copyable
fieldName="wrenOrderId"
/>
{order.certificateUrl && (
<div className="py-3">
<div className="text-xs font-medium text-deep-sea-blue/60 mb-1">
Certificate URL
</div>
<a
href={order.certificateUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-maritime-teal hover:text-maritime-teal/80 flex items-center font-medium"
>
View Certificate
<ExternalLink className="w-4 h-4 ml-1" />
</a>
</div>
)}
<DetailField label="Fulfilled At" value={formatDate(order.fulfilledAt)} />
</div>
</section>
{/* Vessel Information (if applicable) */}
{(order.vesselName || order.imoNumber || order.vesselType || order.vesselLength) && (
<section>
<SectionHeader title="Vessel Information" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Vessel Name" value={order.vesselName} />
<DetailField label="IMO Number" value={order.imoNumber} />
<DetailField label="Vessel Type" value={order.vesselType} />
<DetailField
label="Vessel Length"
value={order.vesselLength ? `${order.vesselLength}m` : undefined}
/>
</div>
</section>
)}
{/* Trip Details (if applicable) */}
{(order.departurePort ||
order.arrivalPort ||
order.distance ||
order.avgSpeed ||
order.duration ||
order.enginePower) && (
<section>
<SectionHeader title="Trip Details" />
<div className="bg-gray-50 rounded-xl p-4">
<DetailField label="Departure Port" value={order.departurePort} />
<DetailField label="Arrival Port" value={order.arrivalPort} />
<DetailField
label="Distance"
value={order.distance ? `${order.distance} nm` : undefined}
/>
<DetailField
label="Average Speed"
value={order.avgSpeed ? `${order.avgSpeed} knots` : undefined}
/>
<DetailField
label="Duration"
value={order.duration ? `${order.duration} hours` : undefined}
/>
<DetailField
label="Engine Power"
value={order.enginePower ? `${order.enginePower} HP` : undefined}
/>
</div>
</section>
)}
{/* Admin Notes */}
{order.notes && (
<section>
<SectionHeader title="Admin Notes" />
<div className="bg-gray-50 rounded-xl p-4">
<p className="text-sm text-deep-sea-blue whitespace-pre-wrap">{order.notes}</p>
</div>
</section>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200 bg-gray-50">
<button
type="button"
onClick={onClose}
className="px-6 py-2.5 text-sm font-medium text-deep-sea-blue bg-white border border-light-gray-border rounded-lg hover:bg-gray-50 transition-colors"
>
Close
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@ -1,198 +0,0 @@
'use client';
import { useState } from 'react';
import { Search, Filter, X, Calendar } from 'lucide-react';
interface OrderFiltersProps {
onSearchChange: (search: string) => void;
onStatusChange: (status: string) => void;
onDateRangeChange: (dateFrom: string, dateTo: string) => void;
onReset: () => void;
searchValue: string;
statusValue: string;
dateFromValue: string;
dateToValue: string;
}
export function OrderFilters({
onSearchChange,
onStatusChange,
onDateRangeChange,
searchValue,
statusValue,
dateFromValue,
dateToValue,
onReset,
}: OrderFiltersProps) {
const [showDatePicker, setShowDatePicker] = useState(false);
const [localDateFrom, setLocalDateFrom] = useState(dateFromValue);
const [localDateTo, setLocalDateTo] = useState(dateToValue);
const handleApplyDateRange = () => {
onDateRangeChange(localDateFrom, localDateTo);
setShowDatePicker(false);
};
const handleResetDateRange = () => {
setLocalDateFrom('');
setLocalDateTo('');
onDateRangeChange('', '');
setShowDatePicker(false);
};
const hasActiveFilters = searchValue || statusValue || dateFromValue || dateToValue;
return (
<div className="bg-white border border-light-gray-border rounded-xl p-6 mb-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search Input */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-deep-sea-blue/40" />
<input
type="text"
placeholder="Search by order ID, customer name, or email..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full pl-11 pr-4 py-2.5 border border-light-gray-border rounded-lg bg-white text-deep-sea-blue placeholder-deep-sea-blue/40 focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20 focus:border-deep-sea-blue transition-all"
/>
</div>
{/* Status Filter */}
<div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-deep-sea-blue/40" />
<select
value={statusValue}
onChange={(e) => onStatusChange(e.target.value)}
className="pl-10 pr-10 py-2.5 border border-light-gray-border rounded-lg bg-white text-deep-sea-blue focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20 focus:border-deep-sea-blue transition-all cursor-pointer appearance-none"
style={{ minWidth: '160px' }}
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
<option value="fulfilled">Fulfilled</option>
<option value="cancelled">Cancelled</option>
</select>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none">
<svg className="w-4 h-4 text-deep-sea-blue/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Date Range Picker */}
<div className="relative">
<button
type="button"
onClick={() => setShowDatePicker(!showDatePicker)}
className="flex items-center space-x-2 px-4 py-2.5 border border-light-gray-border rounded-lg bg-white text-deep-sea-blue hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20 transition-all"
>
<Calendar className="w-4 h-4" />
<span className="text-sm font-medium">
{dateFromValue && dateToValue ? 'Date Range' : 'All Dates'}
</span>
{(dateFromValue || dateToValue) && (
<span className="px-2 py-0.5 text-xs bg-deep-sea-blue text-white rounded-full">1</span>
)}
</button>
{/* Date Picker Dropdown */}
{showDatePicker && (
<div className="absolute right-0 mt-2 p-4 bg-white border border-light-gray-border rounded-xl shadow-lg z-10 min-w-[300px]">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-deep-sea-blue mb-2">From Date</label>
<input
type="date"
value={localDateFrom}
onChange={(e) => setLocalDateFrom(e.target.value)}
className="w-full px-3 py-2 border border-light-gray-border rounded-lg focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20"
/>
</div>
<div>
<label className="block text-sm font-medium text-deep-sea-blue mb-2">To Date</label>
<input
type="date"
value={localDateTo}
onChange={(e) => setLocalDateTo(e.target.value)}
className="w-full px-3 py-2 border border-light-gray-border rounded-lg focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20"
/>
</div>
<div className="flex space-x-2 pt-2">
<button
type="button"
onClick={handleResetDateRange}
className="flex-1 px-4 py-2 text-sm font-medium text-deep-sea-blue bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Clear
</button>
<button
type="button"
onClick={handleApplyDateRange}
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-deep-sea-blue hover:bg-deep-sea-blue/90 rounded-lg transition-colors"
>
Apply
</button>
</div>
</div>
</div>
)}
</div>
{/* Reset Filters Button */}
{hasActiveFilters && (
<button
type="button"
onClick={onReset}
className="flex items-center space-x-2 px-4 py-2.5 text-sm font-medium text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 rounded-lg transition-colors"
>
<X className="w-4 h-4" />
<span>Reset</span>
</button>
)}
</div>
{/* Active Filters Display */}
{hasActiveFilters && (
<div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-light-gray-border">
<span className="text-sm font-medium text-deep-sea-blue/70">Active filters:</span>
{searchValue && (
<span className="inline-flex items-center px-3 py-1 text-xs font-medium bg-deep-sea-blue/10 text-deep-sea-blue rounded-full">
Search: "{searchValue}"
<button
type="button"
onClick={() => onSearchChange('')}
className="ml-2 hover:text-deep-sea-blue/70"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{statusValue && (
<span className="inline-flex items-center px-3 py-1 text-xs font-medium bg-deep-sea-blue/10 text-deep-sea-blue rounded-full">
Status: {statusValue.charAt(0).toUpperCase() + statusValue.slice(1)}
<button
type="button"
onClick={() => onStatusChange('')}
className="ml-2 hover:text-deep-sea-blue/70"
>
<X className="w-3 h-3" />
</button>
</span>
)}
{(dateFromValue || dateToValue) && (
<span className="inline-flex items-center px-3 py-1 text-xs font-medium bg-deep-sea-blue/10 text-deep-sea-blue rounded-full">
Date: {dateFromValue || '...'} to {dateToValue || '...'}
<button
type="button"
onClick={() => onDateRangeChange('', '')}
className="ml-2 hover:text-deep-sea-blue/70"
>
<X className="w-3 h-3" />
</button>
</span>
)}
</div>
)}
</div>
);
}

View File

@ -1,96 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { Package, DollarSign, Leaf, TrendingUp } from 'lucide-react';
interface OrderStats {
totalOrders: number;
totalRevenue: number;
totalCO2Offset: number;
fulfillmentRate: number;
}
interface OrderStatsCardsProps {
stats: OrderStats | null;
isLoading?: boolean;
}
export function OrderStatsCards({ stats, isLoading = false }: OrderStatsCardsProps) {
const statCards = stats
? [
{
title: 'Total Orders',
value: stats.totalOrders.toLocaleString(),
icon: <Package size={24} />,
gradient: 'bg-gradient-to-br from-royal-purple to-purple-600',
},
{
title: 'Total Revenue',
value: `$${(stats.totalRevenue / 100).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`,
icon: <DollarSign size={24} />,
gradient: 'bg-gradient-to-br from-muted-gold to-orange-600',
},
{
title: 'Total CO₂ Offset',
value: `${(typeof stats.totalCO2Offset === 'number' ? stats.totalCO2Offset : parseFloat(stats.totalCO2Offset || '0')).toFixed(2)} tons`,
icon: <Leaf size={24} />,
gradient: 'bg-gradient-to-br from-sea-green to-green-600',
},
{
title: 'Fulfillment Rate',
value: `${(typeof stats.fulfillmentRate === 'number' ? stats.fulfillmentRate : parseFloat(stats.fulfillmentRate || '0')).toFixed(1)}%`,
icon: <TrendingUp size={24} />,
gradient: 'bg-gradient-to-br from-maritime-teal to-teal-600',
},
]
: [];
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{[...Array(4)].map((_, index) => (
<div
key={index}
className="bg-white border border-light-gray-border rounded-xl p-6 animate-pulse"
>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 rounded-lg bg-gray-200"></div>
</div>
<div className="h-4 bg-gray-200 rounded w-24 mb-2"></div>
<div className="h-8 bg-gray-200 rounded w-32"></div>
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat, index) => (
<motion.div
key={stat.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="bg-white border border-light-gray-border rounded-xl p-6 hover:shadow-lg transition-shadow"
>
<div className="flex items-center justify-between mb-4">
<div
className={
stat.gradient +
' w-12 h-12 rounded-lg flex items-center justify-center text-white shadow-md'
}
>
{stat.icon}
</div>
</div>
<h3 className="text-sm font-medium text-deep-sea-blue/60 mb-1">{stat.title}</h3>
<p className="text-2xl font-bold text-deep-sea-blue">{stat.value}</p>
</motion.div>
))}
</div>
);
}

View File

@ -1,297 +0,0 @@
'use client';
import { motion } from 'framer-motion';
import { ChevronUp, ChevronDown, Eye, Loader2 } from 'lucide-react';
import { OrderRecord } from '@/src/types';
interface OrdersTableProps {
orders: OrderRecord[];
isLoading?: boolean;
onViewDetails: (order: OrderRecord) => void;
totalCount: number;
currentPage: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
onSort: (key: keyof OrderRecord) => void;
sortKey: keyof OrderRecord | null;
sortOrder: 'asc' | 'desc';
}
export function OrdersTable({
orders,
isLoading = false,
onViewDetails,
totalCount,
currentPage,
pageSize,
onPageChange,
onPageSizeChange,
onSort,
sortKey,
sortOrder,
}: OrdersTableProps) {
const totalPages = Math.ceil(totalCount / pageSize);
const getStatusBadgeClass = (status: string) => {
switch (status) {
case 'pending':
return 'bg-gradient-to-r from-muted-gold/20 to-orange-400/20 text-muted-gold border border-muted-gold/30';
case 'paid':
return 'bg-gradient-to-r from-maritime-teal/20 to-teal-400/20 text-maritime-teal border border-maritime-teal/30';
case 'fulfilled':
return 'bg-gradient-to-r from-sea-green/20 to-green-400/20 text-sea-green border border-sea-green/30';
case 'cancelled':
return 'bg-gradient-to-r from-red-500/20 to-red-400/20 text-red-600 border border-red-500/30';
default:
return 'bg-gray-100 text-gray-700 border border-gray-300';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
const formatCurrency = (amount: string, currency: string = 'USD') => {
const numAmount = parseFloat(amount) / 100;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency || 'USD',
}).format(numAmount);
};
const getSortIcon = (column: keyof OrderRecord) => {
if (sortKey !== column) {
return <ChevronUp className="w-4 h-4 text-gray-400" />;
}
return sortOrder === 'asc' ? (
<ChevronUp className="w-4 h-4 text-deep-sea-blue" />
) : (
<ChevronDown className="w-4 h-4 text-deep-sea-blue" />
);
};
if (isLoading) {
return (
<div className="bg-white border border-light-gray-border rounded-xl overflow-hidden">
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-deep-sea-blue animate-spin" />
</div>
</div>
);
}
if (orders.length === 0) {
return (
<div className="bg-white border border-light-gray-border rounded-xl overflow-hidden">
<div className="flex flex-col items-center justify-center h-64 text-center px-4">
<div className="text-6xl mb-4">📦</div>
<h3 className="text-xl font-bold text-deep-sea-blue mb-2">No Orders Found</h3>
<p className="text-deep-sea-blue/60">There are no orders matching your criteria.</p>
</div>
</div>
);
}
return (
<div className="bg-white border border-light-gray-border rounded-xl overflow-hidden">
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-light-gray-border">
<tr>
<th
onClick={() => onSort('orderId')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Order ID</span>
{getSortIcon('orderId')}
</div>
</th>
<th
onClick={() => onSort('customerName')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Customer</span>
{getSortIcon('customerName')}
</div>
</th>
<th
onClick={() => onSort('totalAmount')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Amount</span>
{getSortIcon('totalAmount')}
</div>
</th>
<th
onClick={() => onSort('co2Tons')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>CO Offset</span>
{getSortIcon('co2Tons')}
</div>
</th>
<th
onClick={() => onSort('status')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Status</span>
{getSortIcon('status')}
</div>
</th>
<th
onClick={() => onSort('CreatedAt')}
className="px-6 py-4 text-left text-xs font-semibold text-deep-sea-blue uppercase tracking-wider cursor-pointer hover:bg-gray-100 transition-colors"
>
<div className="flex items-center space-x-1">
<span>Created</span>
{getSortIcon('CreatedAt')}
</div>
</th>
<th className="px-6 py-4 text-right text-xs font-semibold text-deep-sea-blue uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-light-gray-border">
{orders.map((order, index) => (
<motion.tr
key={order.Id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-mono text-deep-sea-blue">
{order.orderId ? `${order.orderId.substring(0, 8)}...` : 'N/A'}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-deep-sea-blue">{order.customerName}</div>
<div className="text-xs text-deep-sea-blue/60">{order.customerEmail}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-deep-sea-blue">
{formatCurrency(order.totalAmount, order.currency)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold bg-sea-green/20 text-sea-green border border-sea-green/30">
{parseFloat(order.co2Tons).toFixed(2)} tons
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold ${getStatusBadgeClass(
order.status
)}`}
>
{order.status ? order.status.charAt(0).toUpperCase() + order.status.slice(1) : 'Unknown'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-deep-sea-blue/70">
{formatDate(order.CreatedAt)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<button
onClick={() => onViewDetails(order)}
className="inline-flex items-center px-3 py-1.5 text-sm font-medium text-deep-sea-blue bg-deep-sea-blue/10 hover:bg-deep-sea-blue hover:text-white rounded-lg transition-colors"
>
<Eye className="w-4 h-4 mr-1" />
View
</button>
</td>
</motion.tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="px-6 py-4 border-t border-light-gray-border bg-gray-50">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-deep-sea-blue/70">Rows per page:</span>
<select
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="px-3 py-1.5 text-sm border border-light-gray-border rounded-lg bg-white text-deep-sea-blue focus:outline-none focus:ring-2 focus:ring-deep-sea-blue/20"
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<div className="text-sm text-deep-sea-blue/70">
Showing {(currentPage - 1) * pageSize + 1} to{' '}
{Math.min(currentPage * pageSize, totalCount)} of {totalCount} orders
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className="px-4 py-2 text-sm font-medium text-deep-sea-blue bg-white border border-light-gray-border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous
</button>
<div className="hidden sm:flex items-center space-x-1">
{Array.from({ length: Math.min(totalPages, 5) }).map((_, i) => {
let pageNumber;
if (totalPages <= 5) {
pageNumber = i + 1;
} else if (currentPage <= 3) {
pageNumber = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNumber = totalPages - 4 + i;
} else {
pageNumber = currentPage - 2 + i;
}
if (pageNumber < 1 || pageNumber > totalPages) return null;
return (
<button
key={pageNumber}
onClick={() => onPageChange(pageNumber)}
className={`px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
currentPage === pageNumber
? 'bg-deep-sea-blue text-white'
: 'text-deep-sea-blue bg-white border border-light-gray-border hover:bg-gray-50'
}`}
>
{pageNumber}
</button>
);
})}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="px-4 py-2 text-sm font-medium text-deep-sea-blue bg-white border border-light-gray-border rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Next
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
version: '3.8' version: '3.8'
services: services:
# Frontend - Next.js App # Frontend - Vite React App (static files served by host Nginx)
web: web:
image: code.puffinoffset.com/matt/puffin-app:frontend-latest image: code.puffinoffset.com/matt/puffin-app:frontend-latest
container_name: puffin-frontend container_name: puffin-frontend
@ -9,18 +9,11 @@ services:
- "3800:3000" - "3800:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
# Next.js environment variables (NEXT_PUBLIC_ prefix for client-side) - VITE_API_BASE_URL=${VITE_API_BASE_URL:-https://api.puffinoffset.com}
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL:-https://api.puffinoffset.com}
- NEXT_PUBLIC_WREN_API_TOKEN=${NEXT_PUBLIC_WREN_API_TOKEN}
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
# Backward compatibility fallbacks (remove after migration)
- VITE_API_BASE_URL=${VITE_API_BASE_URL}
- VITE_WREN_API_TOKEN=${VITE_WREN_API_TOKEN} - VITE_WREN_API_TOKEN=${VITE_WREN_API_TOKEN}
- VITE_FORMSPREE_CONTACT_ID=${VITE_FORMSPREE_CONTACT_ID}
- VITE_FORMSPREE_OFFSET_ID=${VITE_FORMSPREE_OFFSET_ID}
- VITE_STRIPE_PUBLISHABLE_KEY=${VITE_STRIPE_PUBLISHABLE_KEY} - VITE_STRIPE_PUBLISHABLE_KEY=${VITE_STRIPE_PUBLISHABLE_KEY}
# Admin Portal Authentication (server-side only)
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- JWT_SECRET=${JWT_SECRET}
restart: unless-stopped restart: unless-stopped
networks: networks:
- puffin-network - puffin-network

View File

@ -1,335 +0,0 @@
# NocoDB Integration Guide
## 🎯 Overview
This guide explains how to test and verify the NocoDB integration with Stripe webhooks.
## ✅ Implementation Summary
### Files Created/Modified
**New Files:**
- `server/utils/nocodbClient.js` - NocoDB REST API client
- `server/utils/nocodbMapper.js` - Maps Stripe data to NocoDB format
- `docs/NOCODB_INTEGRATION_GUIDE.md` - This file
**Modified Files:**
- `server/routes/webhooks.js` - Added NocoDB integration
- `src/api/nocodbClient.ts` - Added `createOrder()` method
- `.env.example` - Added NocoDB configuration template
### Integration Flow
```
Stripe Payment Complete
Webhook: checkout.session.completed
logStripeSessionData() - Comprehensive logging
Order.updateStatus() - Update local DB
saveOrderToNocoDB() - Save to NocoDB ✨ NEW
fulfillOrder() - Create Wren offset
updateNocoDBFulfillment() - Update with Wren data ✨ NEW
Send receipt email
```
## 🔧 Environment Variables
Ensure these are set in `server/.env`:
```bash
# NocoDB Configuration
NOCODB_BASE_URL=https://database.puffinoffset.com
NOCODB_BASE_ID=p11p8be6tzttkhy
NOCODB_API_KEY=Y1thvyr9N53n8WFn7rCgY3ZnLlzPFc4_BdwXCmx-
NOCODB_ORDERS_TABLE_ID=mxusborf4x91e1j
```
**How to find these values:**
1. **NOCODB_BASE_URL**: Your NocoDB instance URL
2. **NOCODB_BASE_ID**: Go to Base → Copy Base ID
3. **NOCODB_API_KEY**: User Settings → Tokens → Create Token
4. **NOCODB_ORDERS_TABLE_ID**: Open table → URL contains table ID
## 🧪 Testing the Integration
### Method 1: Stripe CLI Test (Recommended)
**Setup:**
```bash
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# In another terminal, start the server
cd server
npm run dev
```
**Trigger Test Payment:**
```bash
stripe trigger checkout.session.completed
```
**Expected Console Output:**
```
📬 Received webhook: checkout.session.completed
╔════════════════════════════════════════════════════════════════╗
║ STRIPE CHECKOUT SESSION - COMPREHENSIVE DATA ║
╚════════════════════════════════════════════════════════════════╝
... (all Stripe data logged)
✅ Checkout session completed: cs_test_...
💳 Payment confirmed for order: e0e976e5-...
Amount: $1649.79
💾 Saving order e0e976e5-... to NocoDB...
✅ Order saved to NocoDB successfully
NocoDB Record ID: 123
Order ID: e0e976e5-...
Customer: Matthew Ciaccio (matt@letsbe.solutions)
Business: LetsBe Solutions LLC
Tax ID: eu_vat - FRAB123456789
🌱 Fulfilling order via Wren API...
✅ Order fulfilled successfully
Wren Order ID: wren_...
Tons offset: 6.73
💾 Updating NocoDB with fulfillment data...
✅ NocoDB updated with fulfillment data
Wren Order ID: wren_...
Status: fulfilled
📧 Receipt email sent to matt@letsbe.solutions
```
### Method 2: Real Test Payment
1. **Start Server:**
```bash
cd server
npm run dev
```
2. **Make Test Payment:**
- Navigate to checkout
- Use test card: `4242 4242 4242 4242`
- Fill in test billing information
- Complete payment
3. **Check Logs:**
- Look for NocoDB save confirmation
- Verify fulfillment update logs
4. **Verify in NocoDB:**
- Open NocoDB Orders table
- Find the new order by `orderId`
- Check all fields are populated
## 🔍 Verification Checklist
After a test payment, verify:
### ✅ Server Logs
- [ ] Comprehensive Stripe data logged
- [ ] "Saving order to NocoDB" message
- [ ] "Order saved to NocoDB successfully" with Record ID
- [ ] "Updating NocoDB with fulfillment data" message
- [ ] "NocoDB updated with fulfillment data" message
### ✅ NocoDB Database
- [ ] New record exists in Orders table
- [ ] `orderId` matches Stripe order
- [ ] `status` = "fulfilled" (after Wren fulfillment)
- [ ] `stripeSessionId` populated
- [ ] `stripePaymentIntent` populated
- [ ] `baseAmount`, `processingFee`, `totalAmount` correct
- [ ] `co2Tons`, `portfolioId` from metadata
- [ ] `customerName`, `customerEmail` populated
- [ ] `customerPhone` populated (if collected)
- [ ] `businessName` populated (if B2B)
- [ ] `taxIdType` and `taxIdValue` populated (if collected)
- [ ] `billingLine1`, `billingCity`, `billingCountry` populated
- [ ] `wrenOrderId` populated after fulfillment
- [ ] `fulfilledAt` timestamp set
### ✅ Error Handling
Test error scenarios:
- [ ] NocoDB temporarily unavailable (should log error but not fail webhook)
- [ ] Invalid NocoDB credentials (should skip gracefully)
- [ ] Wren API failure (should still save to NocoDB with status='paid')
## 🐛 Troubleshooting
### Issue: "NocoDB not configured"
**Cause:** Environment variables missing or incorrect
**Fix:**
```bash
# Check environment variables
cd server
cat .env | grep NOCODB
# Ensure all 4 variables are set:
# NOCODB_BASE_URL
# NOCODB_BASE_ID
# NOCODB_API_KEY
# NOCODB_ORDERS_TABLE_ID
```
### Issue: "NocoDB request failed: 401"
**Cause:** Invalid API key
**Fix:**
1. Go to NocoDB → User Settings → Tokens
2. Create new token
3. Update `NOCODB_API_KEY` in `.env`
4. Restart server
### Issue: "NocoDB request failed: 404"
**Cause:** Incorrect table ID or base ID
**Fix:**
1. Open your Orders table in NocoDB
2. Check URL: `https://nocodb.com/nc/BASE_ID/TABLE_ID`
3. Update `NOCODB_BASE_ID` and `NOCODB_ORDERS_TABLE_ID`
4. Restart server
### Issue: Order saved but fields are null
**Cause:** Stripe metadata not passed correctly
**Fix:**
Check checkout session creation includes metadata:
```javascript
const session = await stripe.checkout.sessions.create({
metadata: {
baseAmount: '160174',
processingFee: '4805',
portfolioId: '37',
tons: '6.73',
// Add vessel/trip data if available
},
// ... other settings
});
```
### Issue: Business fields not populated
**Cause:** Stripe checkout not collecting business information
**Fix:**
Update checkout session to collect business details:
```javascript
const session = await stripe.checkout.sessions.create({
customer_creation: 'always',
tax_id_collection: {
enabled: true,
required: 'never' // or 'if_supported'
},
// ... other settings
});
```
## 📊 Database Record Example
**Expected NocoDB Record (Business Customer):**
```json
{
"Id": 123,
"CreatedAt": "2025-11-03T14:30:00.000Z",
"UpdatedAt": "2025-11-03T14:31:00.000Z",
"orderId": "e0e976e5-4272-4f5b-a379-c059df6cb5de",
"status": "fulfilled",
"source": "web",
"stripeSessionId": "cs_test_...",
"stripePaymentIntent": "pi_...",
"stripeCustomerId": "cus_TM7pU6vRGh0N5N",
"baseAmount": "160174",
"processingFee": "4805",
"totalAmount": "164979",
"currency": "USD",
"amountUSD": "164979",
"paymentMethod": "card",
"co2Tons": "6.73",
"portfolioId": "37",
"portfolioName": null,
"wrenOrderId": "wren_order_123",
"certificateUrl": null,
"fulfilledAt": "2025-11-03T14:31:00.000Z",
"customerName": "LetsBe Solutions LLC",
"customerEmail": "matt@letsbe.solutions",
"customerPhone": "+33633219796",
"businessName": "LetsBe Solutions LLC",
"taxIdType": "eu_vat",
"taxIdValue": "FRAB123456789",
"billingLine1": "108 Avenue du Trois Septembre",
"billingLine2": null,
"billingCity": "Cap-d'Ail",
"billingState": null,
"billingPostalCode": "06320",
"billingCountry": "FR",
"vesselName": null,
"imoNumber": null,
"vesselType": null,
"vesselLength": null,
"departurePort": null,
"arrivalPort": null,
"distance": null,
"avgSpeed": null,
"duration": null,
"enginePower": null,
"notes": null
}
```
## 🚀 Deployment Checklist
Before deploying to production:
- [ ] All NocoDB columns created (42 total)
- [ ] Environment variables configured in production `.env`
- [ ] NocoDB API key has proper permissions
- [ ] Stripe webhook endpoint configured in Stripe Dashboard
- [ ] Webhook signing secret set in production `.env`
- [ ] Test payment processed successfully
- [ ] Verify data appears correctly in NocoDB
- [ ] Error handling tested (NocoDB unavailable scenario)
- [ ] Logs reviewed for any warnings or errors
## 📝 Next Steps
1. **Test with Real Payment:**
- Make a test purchase
- Verify all fields in NocoDB
- Check fulfillment updates correctly
2. **Enable Additional Features (Optional):**
- Phone number collection in checkout
- Tax ID collection for B2B customers
- Vessel/trip metadata for yacht calculations
3. **Admin Portal Integration:**
- Connect admin dashboard to NocoDB
- Display orders table with filters
- Add order management functionality
4. **Monitoring:**
- Set up alerts for failed NocoDB saves
- Monitor webhook processing times
- Track fulfillment success rate
## 📚 Related Documentation
- **Schema Reference:** `docs/NOCODB_SCHEMA.md`
- **Stripe Webhook Testing:** `docs/TESTING_STRIPE_WEBHOOK.md`
- **Integration Summary:** `docs/STRIPE_INTEGRATION_SUMMARY.md`
---
**Status:** ✅ Integration complete and ready for testing
**Last Updated:** 2025-11-03

View File

@ -1,212 +0,0 @@
# NocoDB Orders Table Schema
## Proposed Schema (Updated 2025-11-03)
### Core Identification
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `Id` | Number | Yes (auto) | NocoDB record ID |
| `CreatedAt` | DateTime | Yes (auto) | Record creation timestamp |
| `UpdatedAt` | DateTime | Yes (auto) | Record last update timestamp |
| `orderId` | String | Yes | Unique order identifier (UUID) |
| `status` | Enum | Yes | Order status: `pending`, `paid`, `fulfilled`, `cancelled` |
| `source` | String | No | Order source: `web`, `mobile-app`, `manual`, `api` |
### Payment Information
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `stripeSessionId` | String | No | Stripe Checkout Session ID |
| `stripePaymentIntent` | String | No | Stripe Payment Intent ID (for refunds) |
| `baseAmount` | String | Yes | Pre-fee amount in cents (e.g., "160174") |
| `processingFee` | String | Yes | Stripe processing fee in cents (e.g., "4805") |
| `totalAmount` | String | Yes | Total charged amount in cents (baseAmount + processingFee) |
| `currency` | String | Yes | Currency code: `USD`, `EUR`, `GBP`, `CHF` |
| `amountUSD` | String | No | Amount converted to USD for reporting |
| `paymentMethod` | String | No | Payment method type (e.g., "card", "bank_transfer") |
### Carbon Offset Details
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `co2Tons` | String | Yes | Tons of CO2 offset (e.g., "6.73") |
| `portfolioId` | String | Yes | Wren portfolio ID (e.g., "37") |
| `portfolioName` | String | No | Human-readable portfolio name |
| `wrenOrderId` | String | No | Wren API order ID (populated after fulfillment) |
| `certificateUrl` | String | No | URL to offset certificate |
| `fulfilledAt` | DateTime | No | Timestamp when order was fulfilled with Wren |
### Customer Information
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `customerName` | String | Yes | Customer display name (business or individual) |
| `customerEmail` | String | Yes | Customer email address |
| `customerPhone` | String | No | Customer phone (if phone collection enabled in Stripe) |
| `businessName` | String | No | Business name for B2B purchases |
| `stripeCustomerId` | String | No | Stripe Customer ID (for recurring customers) |
| `taxIdType` | String | No | Tax ID type (e.g., "eu_vat", "us_ein", "gb_vat") |
| `taxIdValue` | String | No | Tax identification number |
| `billingCity` | String | No | Billing address city |
| `billingCountry` | String | No | Billing address country code (e.g., "FR") |
| `billingLine1` | String | No | Billing address line 1 |
| `billingLine2` | String | No | Billing address line 2 |
| `billingPostalCode` | String | No | Billing address postal/zip code |
| `billingState` | String | No | Billing address state/region |
### Vessel Information (Optional - for yacht calculations)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `vesselName` | String | No | Name of vessel |
| `imoNumber` | String | No | IMO vessel identification number |
| `vesselType` | String | No | Type of vessel (e.g., "Motor Yacht") |
| `vesselLength` | String | No | Vessel length in meters |
### Trip Details (Optional - for trip-based calculations)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `departurePort` | String | No | Departure port name |
| `arrivalPort` | String | No | Arrival port name |
| `distance` | String | No | Distance in nautical miles |
| `avgSpeed` | String | No | Average speed in knots |
| `duration` | String | No | Trip duration in hours |
| `enginePower` | String | No | Engine power in horsepower |
### Administrative
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `notes` | LongText | No | Internal admin notes |
---
## Changes from Original Schema
### ✅ Added Fields
**Payment & Stripe Integration:**
- `stripeSessionId` - Link to Stripe Checkout Session
- `stripePaymentIntent` - Link to Stripe Payment Intent
- `stripeCustomerId` - Reusable Stripe Customer ID
- `baseAmount` - Pre-fee amount from Stripe metadata
- `processingFee` - Stripe fee from metadata
**Billing Address:**
- `billingCity` - From Stripe address
- `billingCountry` - From Stripe address
- `billingLine1` - From Stripe address
- `billingLine2` - From Stripe address
- `billingPostalCode` - From Stripe address
- `billingState` - From Stripe address
**B2B Customer Support:**
- `businessName` - Business name for B2B purchases
- `taxIdType` - Tax ID type (eu_vat, us_ein, gb_vat, etc.)
- `taxIdValue` - Tax identification number
### ⚠️ Modified Fields
- `customerPhone` - Now optional, populated from Stripe if phone collection is enabled
- `customerName` - Now serves as display name (either business_name or individual_name)
### ❌ Removed Fields
- `customerCompany` - Not provided by Stripe, can be added manually if needed
- `paymentReference` - Redundant with `stripePaymentIntent`
### ⚠️ Made Optional
All vessel and trip fields are now optional since they only apply to specific order types (not all orders are for vessel trips).
---
## Stripe Webhook Mapping
When receiving Stripe webhook `checkout.session.completed`:
```typescript
{
// Payment Information
stripeSessionId: session.id,
stripePaymentIntent: session.payment_intent,
baseAmount: session.metadata.baseAmount, // in cents
processingFee: session.metadata.processingFee, // in cents
totalAmount: session.amount_total.toString(), // in cents
currency: session.currency,
paymentMethod: session.payment_method_types[0], // 'card', 'us_bank_account', etc.
// Carbon Offset Details
co2Tons: session.metadata.tons,
portfolioId: session.metadata.portfolioId,
// Customer Information
customerName: session.customer_details.name, // Display name (business or individual)
customerEmail: session.customer_details.email,
customerPhone: session.customer_details.phone, // if phone collection enabled
// Business Customer Fields (B2B)
businessName: session.customer_details.business_name, // For B2B purchases
stripeCustomerId: session.customer, // Reusable customer ID
// Tax Collection (if enabled)
taxIdType: session.customer_details.tax_ids?.[0]?.type, // 'eu_vat', 'us_ein', etc.
taxIdValue: session.customer_details.tax_ids?.[0]?.value, // Tax number
// Billing Address
billingCity: session.customer_details.address?.city,
billingCountry: session.customer_details.address?.country,
billingLine1: session.customer_details.address?.line1,
billingLine2: session.customer_details.address?.line2,
billingPostalCode: session.customer_details.address?.postal_code,
billingState: session.customer_details.address?.state,
// Order Status
status: 'paid'
}
```
### Real-World Example (Business Purchase)
From actual Stripe payload `evt_1SPPa3Pdj1mnVT5kscrqB21t`:
```typescript
{
stripeSessionId: "cs_test_b1HSYDGs73Ail2Vumu0qC3yu96ce9X4qnozsDr5hDwRndpZOsq8H47flLc",
stripePaymentIntent: "pi_3SPPa2Pdj1mnVT5k2qsmDiV1",
stripeCustomerId: "cus_TM7pU6vRGh0N5N",
baseAmount: "16023588", // $160,235.88
processingFee: "480708", // $4,807.08
totalAmount: "16504296", // $165,042.96
currency: "usd",
co2Tons: "673.26",
portfolioId: "37",
customerName: "LetsBe Solutions LLC", // Business name used as display name
customerEmail: "matt@letsbe.solutions",
customerPhone: "+33633219796",
businessName: "LetsBe Solutions LLC",
taxIdType: "eu_vat",
taxIdValue: "FRAB123456789",
billingLine1: "108 Avenue du Trois Septembre",
billingLine2: null,
billingCity: "Cap-d'Ail",
billingState: null,
billingPostalCode: "06320",
billingCountry: "FR",
paymentMethod: "card",
status: "paid"
}
```
---
## Field Type Notes
**Why String for numeric fields?**
NocoDB stores all custom fields as strings by default. Numeric calculations should be done in application code by parsing these strings. This prevents precision issues with currency and decimal values.
**Date/DateTime fields:**
- `CreatedAt`, `UpdatedAt`, `fulfilledAt` use NocoDB's DateTime type
- ISO 8601 format: `2025-11-03T14:30:00.000Z`
**Enum constraints:**
- `status`: Must be one of `pending`, `paid`, `fulfilled`, `cancelled`
- `currency`: Must be one of `USD`, `EUR`, `GBP`, `CHF`
- `source`: Typically `web`, `mobile-app`, `manual`, or `api`

View File

@ -1,232 +0,0 @@
# Stripe Integration & Database Schema - Summary
## 📋 Overview
This document summarizes the database schema updates and Stripe webhook logging enhancements completed on 2025-11-03.
## ✅ Completed Tasks
### 1. Enhanced Webhook Logging
**File:** `server/routes/webhooks.js`
Added comprehensive logging function `logStripeSessionData()` that displays:
- Session information (ID, payment intent, status, timestamps)
- Payment amounts (total, subtotal, currency)
- Customer details (name, business name, individual name, email, phone)
- Billing address (all fields)
- Business customer fields (business name, tax IDs)
- Payment method types
- Metadata (custom fields)
- Additional fields (locale, customer ID, etc.)
**Output Format:**
```
╔════════════════════════════════════════════════════════════════╗
║ STRIPE CHECKOUT SESSION - COMPREHENSIVE DATA ║
╚════════════════════════════════════════════════════════════════╝
📋 SESSION INFORMATION:
💰 PAYMENT AMOUNT:
👤 CUSTOMER DETAILS:
📬 BILLING ADDRESS:
💳 PAYMENT METHOD:
🏷️ METADATA:
... (and more)
```
### 2. Updated Database Schema
**Files:**
- `docs/NOCODB_SCHEMA.md` (comprehensive documentation)
- `src/types.ts` (TypeScript interfaces)
#### Added Fields (19 new fields)
**Payment & Stripe Integration (5 fields):**
- `stripeSessionId` - Link to Stripe Checkout Session
- `stripePaymentIntent` - For refunds/payment verification
- `stripeCustomerId` - Reusable customer ID
- `baseAmount` - Pre-fee amount (from metadata)
- `processingFee` - Stripe processing fee (from metadata)
**Billing Address (6 fields):**
- `billingCity`
- `billingCountry`
- `billingLine1`
- `billingLine2`
- `billingPostalCode`
- `billingState`
**B2B Customer Support (3 fields):**
- `businessName` - Business name for B2B purchases
- `taxIdType` - Tax ID type (eu_vat, us_ein, gb_vat, etc.)
- `taxIdValue` - Tax identification number
**Customer Details (1 field):**
- `customerPhone` - Customer phone number (if collected)
#### Removed Fields (2 fields)
- `customerCompany` - Redundant with `businessName`
- `paymentReference` - Redundant with `stripePaymentIntent`
#### Made Optional
All vessel and trip fields (since not all orders are yacht trips):
- `vesselName`, `imoNumber`, `vesselType`, `vesselLength`
- `departurePort`, `arrivalPort`, `distance`, `avgSpeed`, `duration`, `enginePower`
### 3. Real-World Data Verification
Analyzed actual business payment from Stripe (event: `evt_1SPPa3Pdj1mnVT5kscrqB21t`):
```typescript
{
amount: $165,042.96,
baseAmount: $160,235.88,
processingFee: $4,807.08,
businessName: "LetsBe Solutions LLC",
taxIdType: "eu_vat",
taxIdValue: "FRAB123456789",
customerPhone: "+33633219796",
billingCountry: "FR"
// ... all other fields
}
```
### 4. Documentation Created
- **`docs/NOCODB_SCHEMA.md`** - Complete field reference with:
- Field definitions table
- Required vs optional indicators
- Stripe webhook mapping guide
- Real-world example data
- Field type notes
- Change log
- **`docs/TESTING_STRIPE_WEBHOOK.md`** - Testing guide with:
- Three testing methods (Stripe CLI, real payments, dashboard)
- Expected log output examples
- Verification checklist
- Common issues & fixes
- Test data to use
- **`docs/STRIPE_INTEGRATION_SUMMARY.md`** - This file
## 📊 Final Schema Statistics
**Total Fields:** 42 fields
- **Required:** 10 fields
- **Optional:** 32 fields
**Categories:**
- Core Identification: 6 fields
- Payment Information: 8 fields
- Carbon Offset Details: 6 fields
- Customer Information: 13 fields
- Vessel Information: 4 fields (optional)
- Trip Details: 6 fields (optional)
- Administrative: 1 field
## 🎯 Key Features
### B2B Support
- Full business customer information capture
- Tax ID collection (VAT, EIN, etc.)
- Business name separate from individual name
- Reusable customer profiles via Stripe Customer ID
### Payment Transparency
- Separate base amount and processing fee tracking
- Full Stripe payment references for refunds
- Multiple payment method support
- Currency conversion tracking (amountUSD)
### Comprehensive Billing
- Complete billing address capture
- Phone number collection (optional)
- Country/region support for international customers
### Flexible Use Cases
- Yacht trip calculations (vessel/trip fields optional)
- Direct offset purchases (no vessel required)
- Multiple order sources (web, mobile-app, manual, api)
## 🔄 Data Flow
```
Checkout Form
Stripe Checkout Session
↓ (metadata)
Stripe Payment Success
↓ (webhook)
Server Webhook Handler
↓ (logStripeSessionData)
Enhanced Logging
↓ (session data)
NocoDB Database
Admin Portal Display
```
## 🧪 Testing Status
**Webhook Logging Enhanced** - Comprehensive data display implemented
**Pending:** Live webhook testing with real payment
**Pending:** NocoDB table creation
**Pending:** Webhook-to-database mapping implementation
## 📝 Next Steps
1. **Test Enhanced Logging**
- Run test payment through Stripe
- Verify all fields appear in logs
- Document any missing fields
2. **Create NocoDB Table**
- Use schema from `docs/NOCODB_SCHEMA.md`
- Configure field types (String, DateTime, etc.)
- Set required field validation
3. **Implement Database Integration**
- Create `nocodbClient.createOrder()` method
- Map Stripe session data to NocoDB fields
- Call from webhook handler after payment confirmation
4. **Add Webhook Handler**
- Extract all fields from `session` object
- Handle B2B vs individual customer logic
- Store complete record in NocoDB
- Add error handling and retry logic
5. **Enable Phone & Tax Collection**
- Update checkout session creation in `server/routes/checkout.js`
- Add `phone_number_collection: { enabled: true }`
- Add `tax_id_collection: { enabled: true }`
## 📚 Reference Files
- **Schema:** `docs/NOCODB_SCHEMA.md`
- **Testing:** `docs/TESTING_STRIPE_WEBHOOK.md`
- **Types:** `src/types.ts:147-202` (OrderRecord interface)
- **Webhook:** `server/routes/webhooks.js:15-124` (logStripeSessionData)
- **NocoDB Client:** `src/api/nocodbClient.ts`
## 💡 Key Insights from Real Data
1. **Business Customers:** Stripe clearly distinguishes business vs individual:
- `business_name` populated for B2B
- `individual_name` populated for B2C
- `name` serves as display name (either business or individual)
2. **Tax Collection:** When enabled, provides structured tax IDs:
- Type (eu_vat, us_ein, gb_vat, etc.)
- Value (actual tax number)
- Multiple tax IDs possible (array)
3. **Phone Numbers:** Available when `phone_number_collection.enabled: true`
4. **Processing Fees:** Must be calculated and passed in metadata (not automatic)
5. **Customer IDs:** Stripe creates reusable customer objects for future purchases
---
**Status:** ✅ Schema finalized and documented
**Last Updated:** 2025-11-03
**Real Data Verified:** Yes (business purchase with VAT)

View File

@ -1,263 +0,0 @@
# Testing Stripe Webhook Data Collection
## Purpose
This guide explains how to test the Stripe webhook to verify all available data fields before finalizing the NocoDB schema.
## Enhanced Logging
The webhook handler now includes comprehensive logging via `logStripeSessionData()` function that displays:
- ✅ Session information (ID, payment intent, status)
- ✅ Payment amounts (total, subtotal, currency)
- ✅ Customer details (name, email, phone)
- ✅ Billing address (all fields)
- ✅ Payment method types
- ✅ Metadata (our custom fields)
- ✅ Additional fields (locale, reference IDs)
- ✅ Shipping address (if collected)
- ✅ Tax IDs (if collected)
## Testing Methods
### Method 1: Stripe CLI (Recommended for Development)
**Setup:**
```bash
# Install Stripe CLI if not already installed
# Windows: scoop install stripe
# macOS: brew install stripe/stripe-cli/stripe
# Linux: See https://stripe.com/docs/stripe-cli
# Login to Stripe
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
```
**Trigger Test Payment:**
```bash
# Trigger a checkout.session.completed event
stripe trigger checkout.session.completed
```
**Expected Output:**
The server console will show:
1. The structured data log from `logStripeSessionData()` (formatted with boxes)
2. The full JSON payload
3. Payment confirmation message
4. Order fulfillment logs
### Method 2: Real Stripe Test Payment
**Setup:**
1. Ensure server is running: `npm run dev` (from `/server` directory)
2. Ensure webhook endpoint is publicly accessible (use ngrok or similar)
3. Configure webhook in Stripe Dashboard → Developers → Webhooks
4. Add webhook endpoint: `https://your-domain.com/api/webhooks/stripe`
5. Select event: `checkout.session.completed`
6. Copy webhook signing secret to `.env`: `STRIPE_WEBHOOK_SECRET=whsec_...`
**Execute Test:**
1. Navigate to your app's checkout flow
2. Use Stripe test card: `4242 4242 4242 4242`
3. Fill in test billing information
4. Complete checkout
5. Check server logs
**Test Data to Use:**
```
Card Number: 4242 4242 4242 4242
Expiry: Any future date (e.g., 12/34)
CVC: Any 3 digits (e.g., 123)
ZIP: Any 5 digits (e.g., 12345)
Name: Test User
Email: test@example.com
Phone: +1234567890 (if phone collection enabled)
Address:
Line 1: 123 Test Street
Line 2: Apt 4B
City: Test City
State: CA
ZIP: 12345
Country: US
```
### Method 3: Stripe Dashboard Test Events
**Execute:**
1. Go to Stripe Dashboard → Developers → Webhooks
2. Click on your webhook endpoint
3. Click "Send test webhook"
4. Select `checkout.session.completed`
5. Click "Send test webhook"
**Note:** This may have limited data compared to real checkout sessions.
## Reading the Logs
### Expected Log Structure
When a payment completes, you'll see logs in this order:
```
📬 Received webhook: checkout.session.completed
╔════════════════════════════════════════════════════════════════╗
║ STRIPE CHECKOUT SESSION - COMPREHENSIVE DATA ║
╚════════════════════════════════════════════════════════════════╝
📋 SESSION INFORMATION:
Session ID: cs_test_...
Payment Intent: pi_...
Payment Status: paid
Status: complete
Mode: payment
Created: 2025-11-03T...
Expires At: 2025-11-04T...
💰 PAYMENT AMOUNT:
Amount Total: 164979 cents ($1649.79)
Amount Subtotal: 164979 cents ($1649.79)
Currency: USD
👤 CUSTOMER DETAILS:
Name: Matthew Ciaccio
Email: matt@letsbe.solutions
Phone: +33612345678 (or N/A if not collected)
Tax Exempt: none
📬 BILLING ADDRESS:
Line 1: 108 Avenue du Trois Septembre
Line 2: N/A
City: Cap-d'Ail
State: N/A
Postal Code: 06320
Country: FR
🔗 CUSTOMER OBJECT:
Customer ID: cus_... (or N/A)
💳 PAYMENT METHOD:
Types: card
🏷️ METADATA (Our Custom Fields):
baseAmount: 160174
processingFee: 4805
portfolioId: 37
tons: 6.73
... (additional sections)
╔════════════════════════════════════════════════════════════════╗
║ END OF STRIPE DATA LOG ║
╚════════════════════════════════════════════════════════════════╝
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📦 Full Stripe Webhook Payload:
{
"id": "evt_...",
"object": "event",
"type": "checkout.session.completed",
"data": { ... }
}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✅ Checkout session completed: cs_test_...
💳 Payment confirmed for order: e0e976e5-...
Amount: $1649.79
🌱 Fulfilling order via Wren API...
```
## Verification Checklist
After running a test payment, verify the logs contain:
### ✅ Required Fields (Must Have Values)
- [ ] Session ID (`session.id`)
- [ ] Payment Intent (`session.payment_intent`)
- [ ] Amount Total (`session.amount_total`)
- [ ] Currency (`session.currency`)
- [ ] Customer Name (`session.customer_details.name`)
- [ ] Customer Email (`session.customer_details.email`)
- [ ] Metadata - baseAmount (`session.metadata.baseAmount`)
- [ ] Metadata - processingFee (`session.metadata.processingFee`)
- [ ] Metadata - portfolioId (`session.metadata.portfolioId`)
- [ ] Metadata - tons (`session.metadata.tons`)
### ⚠️ Optional Fields (Check if Available)
- [ ] Customer Phone (`session.customer_details.phone`)
- [ ] Billing Address Line 1 (`session.customer_details.address.line1`)
- [ ] Billing Address Line 2 (`session.customer_details.address.line2`)
- [ ] Billing City (`session.customer_details.address.city`)
- [ ] Billing State (`session.customer_details.address.state`)
- [ ] Billing Postal Code (`session.customer_details.address.postal_code`)
- [ ] Billing Country (`session.customer_details.address.country`)
### 📋 Schema Verification
Compare the logged fields against `docs/NOCODB_SCHEMA.md`:
1. Verify all required fields have values
2. Check which optional fields are actually populated
3. Identify any Stripe fields we're missing in our schema
4. Verify data types and formats
## Common Issues
### Phone Number Not Showing
**Cause:** Phone collection not enabled in checkout session creation.
**Fix:** In `server/routes/checkout.js`, ensure phone collection is enabled:
```javascript
const session = await stripe.checkout.sessions.create({
phone_number_collection: {
enabled: true
},
// ... other settings
});
```
### Address Fields Empty
**Cause:** Billing address collection not set to "required".
**Fix:** In checkout session creation:
```javascript
const session = await stripe.checkout.sessions.create({
billing_address_collection: 'required',
// ... other settings
});
```
### Metadata Missing
**Cause:** Metadata not passed when creating checkout session.
**Fix:** Ensure metadata is included in session creation:
```javascript
const session = await stripe.checkout.sessions.create({
metadata: {
baseAmount: '160174',
processingFee: '4805',
portfolioId: '37',
tons: '6.73',
// Add vessel/trip data here if available
},
// ... other settings
});
```
## Next Steps
After verifying the logged data:
1. **Document findings** - Note which fields are actually populated
2. **Update schema** - Add any missing fields to `docs/NOCODB_SCHEMA.md`
3. **Update types** - Update `OrderRecord` interface in `src/types.ts`
4. **Configure NocoDB** - Create table with verified fields
5. **Implement webhook handler** - Map Stripe data to NocoDB columns
## Reference
- [Stripe Checkout Session Object](https://stripe.com/docs/api/checkout/sessions/object)
- [Stripe Webhooks Documentation](https://stripe.com/docs/webhooks)
- [Stripe CLI Documentation](https://stripe.com/docs/stripe-cli)

File diff suppressed because it is too large Load Diff

View File

@ -4,11 +4,6 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/webp" href="/puffinOffset.webp" /> <link rel="icon" type="image/webp" href="/puffinOffset.webp" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Cache Control - Prevent stale cached versions -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />

View File

@ -1,76 +0,0 @@
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-key';
const TOKEN_EXPIRY = '24h'; // Token expires in 24 hours
export interface AdminUser {
username: string;
isAdmin: true;
}
export interface JWTPayload extends AdminUser {
iat: number;
exp: number;
}
/**
* Verify admin credentials against environment variables
*/
export function verifyCredentials(username: string, password: string): boolean {
const adminUsername = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD;
if (!adminUsername || !adminPassword) {
console.error('Admin credentials not configured in environment variables');
return false;
}
return username === adminUsername && password === adminPassword;
}
/**
* Generate JWT token for authenticated admin
*/
export function generateToken(user: AdminUser): string {
return jwt.sign(user, JWT_SECRET, {
expiresIn: TOKEN_EXPIRY,
});
}
/**
* Verify and decode JWT token
* Returns the decoded payload or null if invalid
*/
export function verifyToken(token: string): JWTPayload | null {
try {
const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload;
return decoded;
} catch (error) {
console.error('JWT verification failed:', error);
return null;
}
}
/**
* Extract token from Authorization header
* Supports both "Bearer token" and plain token formats
*/
export function extractToken(authHeader: string | null): string | null {
if (!authHeader) return null;
if (authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
return authHeader;
}
/**
* Check if user is authenticated admin
*/
export function isAuthenticated(token: string | null): boolean {
if (!token) return false;
const payload = verifyToken(token);
return payload !== null && payload.isAdmin === true;
}

View File

@ -1,58 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from './auth';
/**
* Middleware to protect admin API routes
* Returns 401 if not authenticated
*/
export function withAdminAuth(
handler: (request: NextRequest) => Promise<NextResponse>
) {
return async (request: NextRequest) => {
// Get token from cookie
const token = request.cookies.get('admin-token')?.value;
if (!token) {
return NextResponse.json(
{ error: 'Unauthorized - No token provided' },
{ status: 401 }
);
}
// Verify token
const payload = verifyToken(token);
if (!payload || !payload.isAdmin) {
return NextResponse.json(
{ error: 'Unauthorized - Invalid token' },
{ status: 401 }
);
}
// Token is valid, proceed with the request
return handler(request);
};
}
/**
* Check if request is from authenticated admin
* For use in server components and API routes
*/
export function getAdminFromRequest(request: NextRequest) {
const token = request.cookies.get('admin-token')?.value;
if (!token) {
return null;
}
const payload = verifyToken(token);
if (!payload || !payload.isAdmin) {
return null;
}
return {
username: payload.username,
isAdmin: true,
};
}

View File

@ -1,106 +0,0 @@
// Service Worker registration with automatic update detection for Next.js
// This ensures users always get the latest version after deployment
type Config = {
onUpdate?: (registration: ServiceWorkerRegistration) => void;
onSuccess?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
// Wait for page load to avoid impacting initial page load performance
window.addEventListener('load', () => {
const swUrl = `/sw.js`;
registerValidSW(swUrl, config);
// Check for updates every hour
setInterval(() => {
checkForUpdates(swUrl);
}, 60 * 60 * 1000); // 1 hour
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
// Check for updates on initial registration
registration.update();
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// New content is available; please refresh
console.log('New content available! Please refresh.');
// Execute onUpdate callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// Content is cached for offline use
console.log('Content cached for offline use.');
// Execute onSuccess callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkForUpdates(swUrl: string) {
navigator.serviceWorker
.getRegistration(swUrl)
.then((registration) => {
if (registration) {
registration.update();
}
})
.catch((error) => {
console.error('Error checking for service worker updates:', error);
});
}
export function unregister() {
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}
// Force refresh when a new service worker is waiting
export function skipWaitingAndReload() {
if (typeof window !== 'undefined') {
navigator.serviceWorker.ready.then((registration) => {
if (registration.waiting) {
// Tell the waiting service worker to skip waiting and become active
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
// Listen for the controller change and reload
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
}

6
next-env.d.ts vendored
View File

@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@ -1,74 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Enable React strict mode for better development experience
reactStrictMode: true,
// Output standalone for Docker deployment
output: 'standalone',
// Compiler options
compiler: {
// Remove console logs in production
removeConsole: process.env.NODE_ENV === 'production' ? {
exclude: ['error', 'warn'],
} : false,
},
// Image optimization configuration
images: {
domains: ['s3.puffinoffset.com'],
formats: ['image/avif', 'image/webp'],
},
// Headers for security and caching
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-DNS-Prefetch-Control',
value: 'on'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
],
},
// Service Worker - no cache to ensure updates are detected immediately
{
source: '/sw.js',
headers: [
{
key: 'Cache-Control',
value: 'no-cache, no-store, must-revalidate',
},
{
key: 'Service-Worker-Allowed',
value: '/',
},
],
},
];
},
// Turbopack configuration (Next.js 16 default)
turbopack: {},
// Webpack configuration for any custom needs
webpack: (config, { isServer }) => {
// Fixes npm packages that depend on node modules
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
net: false,
tls: false,
};
}
return config;
},
};
export default nextConfig;

View File

@ -86,37 +86,6 @@ server {
} }
} }
# === Next.js Frontend QR Code API ===
# NOTE: This MUST come before the general /api/ backend location block
location /api/qr-code/ {
proxy_pass http://127.0.0.1:3800;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS headers
add_header Access-Control-Allow-Origin '*' always;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
add_header Access-Control-Allow-Headers 'Content-Type, Authorization' always;
# API timeouts
proxy_read_timeout 120;
proxy_connect_timeout 120;
proxy_send_timeout 120;
# Handle OPTIONS for CORS preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin '*';
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'Content-Type, Authorization';
add_header Access-Control-Max-Age 1728000;
add_header Content-Length 0;
return 204;
}
}
# === Backend API - Stripe Webhooks (specific route, no trailing slash) === # === Backend API - Stripe Webhooks (specific route, no trailing slash) ===
location = /api/webhooks/stripe { location = /api/webhooks/stripe {
proxy_pass http://127.0.0.1:3801/api/webhooks/stripe; proxy_pass http://127.0.0.1:3801/api/webhooks/stripe;

1788
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,38 +4,28 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "vite",
"prebuild": "node scripts/inject-sw-version.js", "build": "vite build",
"build": "next build", "lint": "eslint .",
"start": "next start", "preview": "vite preview",
"lint": "next lint",
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@types/jsonwebtoken": "^9.0.10",
"@types/qrcode": "^1.5.6",
"axios": "^1.6.7", "axios": "^1.6.7",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
"framer-motion": "^12.15.0", "framer-motion": "^12.15.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"next": "^16.0.1",
"papaparse": "^5.5.3",
"qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^3.3.0", "recharts": "^3.3.0"
"xlsx": "^0.18.5",
"zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.1", "@eslint/js": "^9.9.1",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1", "@testing-library/react": "^14.2.1",
"@types/node": "24.9.2",
"@types/papaparse": "^5.3.16",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18", "autoprefixer": "^10.4.18",
"eslint": "^9.9.1", "eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
@ -46,6 +36,7 @@
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.3.0", "typescript-eslint": "^8.3.0",
"vite": "^5.4.2",
"vitest": "^1.3.1" "vitest": "^1.3.1"
} }
} }

View File

@ -1,16 +1,17 @@
import { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Bird, Menu, X } from 'lucide-react'; import { Bird, Menu, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Home } from './components/Home'; import { Home } from './components/Home';
import { YachtSearch } from './components/YachtSearch';
import { TripCalculator } from './components/TripCalculator'; import { TripCalculator } from './components/TripCalculator';
import { QRCalculatorLoader } from './components/QRCalculatorLoader';
import { useHasQRData } from './hooks/useQRDecoder';
import { HowItWorks } from './components/HowItWorks'; import { HowItWorks } from './components/HowItWorks';
import { About } from './components/About'; import { About } from './components/About';
import { Contact } from './components/Contact'; import { Contact } from './components/Contact';
import { OffsetOrder } from './components/OffsetOrder'; import { OffsetOrder } from './components/OffsetOrder';
import { getVesselData } from './api/aisClient';
import { calculateTripCarbon } from './utils/carbonCalculator';
import { analytics } from './utils/analytics'; import { analytics } from './utils/analytics';
import type { VesselData, CalculatorType } from './types'; import type { VesselData, CarbonCalculation, CalculatorType } from './types';
const sampleVessel: VesselData = { const sampleVessel: VesselData = {
imo: "1234567", imo: "1234567",
@ -22,18 +23,35 @@ const sampleVessel: VesselData = {
}; };
function App() { function App() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [vesselData, setVesselData] = useState<VesselData | null>(null);
const [currentPage, setCurrentPage] = useState<'home' | 'calculator' | 'how-it-works' | 'about' | 'contact'>('home'); const [currentPage, setCurrentPage] = useState<'home' | 'calculator' | 'how-it-works' | 'about' | 'contact'>('home');
const [showOffsetOrder, setShowOffsetOrder] = useState(false); const [showOffsetOrder, setShowOffsetOrder] = useState(false);
const [offsetTons, setOffsetTons] = useState(0); const [offsetTons, setOffsetTons] = useState(0);
const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>(); const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>();
const [calculatorType, setCalculatorType] = useState<CalculatorType>('trip'); const [calculatorType, setCalculatorType] = useState<CalculatorType>('trip');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const hasQRData = useHasQRData(); // Check if URL contains QR code data
useEffect(() => { useEffect(() => {
analytics.pageView(window.location.pathname); analytics.pageView(window.location.pathname);
}, [currentPage]); }, [currentPage]);
const handleSearch = async (imo: string) => {
setLoading(true);
setError(null);
setVesselData(null);
try {
const vessel = await getVesselData(imo);
setVesselData(vessel);
} catch (err) {
setError('Unable to fetch vessel data. Please verify the IMO number and try again.');
} finally {
setLoading(false);
}
};
const handleOffsetClick = (tons: number, monetaryAmount?: number) => { const handleOffsetClick = (tons: number, monetaryAmount?: number) => {
setOffsetTons(tons); setOffsetTons(tons);
setMonetaryAmount(monetaryAmount); setMonetaryAmount(monetaryAmount);
@ -78,17 +96,10 @@ function App() {
</div> </div>
<div className="flex flex-col items-center w-full max-w-2xl space-y-8"> <div className="flex flex-col items-center w-full max-w-2xl space-y-8">
{hasQRData ? ( <TripCalculator
<QRCalculatorLoader vesselData={sampleVessel}
vesselData={sampleVessel} onOffsetClick={handleOffsetClick}
onOffsetClick={handleOffsetClick} />
/>
) : (
<TripCalculator
vesselData={sampleVessel}
onOffsetClick={handleOffsetClick}
/>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,4 +1,4 @@
import axios, { AxiosError, AxiosResponse } from 'axios'; import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { OffsetOrder, Portfolio } from '../types'; import type { OffsetOrder, Portfolio } from '../types';
import { config } from '../utils/config'; import { config } from '../utils/config';
@ -50,7 +50,7 @@ const createApiClient = () => {
// Add request interceptor for logging // Add request interceptor for logging
client.interceptors.request.use( client.interceptors.request.use(
(config) => { (config: AxiosRequestConfig) => {
if (!config.headers?.Authorization) { if (!config.headers?.Authorization) {
throw new Error('API token is required'); throw new Error('API token is required');
} }

View File

@ -1,4 +1,4 @@
// React import removed - not needed with JSX transform import React from 'react';
import { Anchor, Heart, Leaf, Scale, CreditCard, FileCheck, Handshake, Rocket } from 'lucide-react'; import { Anchor, Heart, Leaf, Scale, CreditCard, FileCheck, Handshake, Rocket } from 'lucide-react';
interface Props { interface Props {

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import React, { useState } from 'react';
import { Leaf } from 'lucide-react'; import { Leaf } from 'lucide-react';
import type { CarbonCalculation, CurrencyCode } from '../types'; import type { CarbonCalculation, CurrencyCode } from '../types';
import { currencies, formatCurrency } from '../utils/currencies'; import { currencies, formatCurrency } from '../utils/currencies';

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import React, { useState } from 'react';
import { Mail, Phone, Loader2, AlertCircle } from 'lucide-react'; import { Mail, Phone, Loader2, AlertCircle } from 'lucide-react';
import { validateEmail, sendFormspreeEmail } from '../utils/email'; import { validateEmail, sendFormspreeEmail } from '../utils/email';
import { analytics } from '../utils/analytics'; import { analytics } from '../utils/analytics';

View File

@ -1,4 +1,4 @@
// React import removed - not needed with JSX transform import React from 'react';
import type { CurrencyCode } from '../types'; import type { CurrencyCode } from '../types';
import { currencies } from '../utils/currencies'; import { currencies } from '../utils/currencies';

View File

@ -1,4 +1,4 @@
import { Component, ErrorInfo, ReactNode } from 'react'; import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
interface Props { interface Props {

View File

@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import { Anchor, Globe, BarChart } from 'lucide-react'; import { Anchor, Globe, BarChart } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';

View File

@ -1,4 +1,4 @@
// React import removed - not needed with JSX transform import React from 'react';
import { Leaf, Anchor, Calculator, Globe, BarChart } from 'lucide-react'; import { Leaf, Anchor, Calculator, Globe, BarChart } from 'lucide-react';
interface Props { interface Props {

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Check, AlertCircle, ArrowLeft, Loader2, Globe2, TreePine, Waves, Factory, Wind, X } from 'lucide-react'; import { Check, AlertCircle, ArrowLeft, Loader2, Globe2, TreePine, Waves, Factory, Wind, X } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { createOffsetOrder, getPortfolios } from '../api/wrenClient'; import { createOffsetOrder, getPortfolios } from '../api/wrenClient';

View File

@ -1,4 +1,4 @@
// React import removed - not needed with JSX transform import React from 'react';
import { Laptop, Leaf, Scale, CreditCard, FileCheck, Handshake } from 'lucide-react'; import { Laptop, Leaf, Scale, CreditCard, FileCheck, Handshake } from 'lucide-react';
interface Props { interface Props {

View File

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { Route } from 'lucide-react'; import { Route } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import type { VesselData, TripEstimate, CurrencyCode } from '../types'; import type { VesselData, TripEstimate, CurrencyCode } from '../types';

View File

@ -1,70 +0,0 @@
import { useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
interface UpdateNotificationProps {
registration: ServiceWorkerRegistration | null;
}
export function UpdateNotification({ registration }: UpdateNotificationProps) {
const [showNotification, setShowNotification] = useState(false);
useEffect(() => {
if (registration) {
setShowNotification(true);
}
}, [registration]);
const handleUpdate = () => {
if (registration && registration.waiting) {
// Tell the service worker to skip waiting
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
// Listen for the controller change and reload
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}
};
const handleDismiss = () => {
setShowNotification(false);
};
if (!showNotification) {
return null;
}
return (
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50 max-w-md w-full mx-4">
<div className="glass-card rounded-lg p-4 shadow-2xl border border-blue-500/30 backdrop-blur-md bg-slate-900/90">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<RefreshCw className="w-6 h-6 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-white">
New version available!
</p>
<p className="text-xs text-slate-300 mt-1">
A new version of Puffin Offset is ready. Please update to get the latest features and improvements.
</p>
</div>
</div>
<div className="mt-4 flex gap-2">
<button
onClick={handleUpdate}
className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Update Now
</button>
<button
onClick={handleDismiss}
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
Later
</button>
</div>
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import React, { useState } from 'react';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
interface Props { interface Props {

View File

@ -1,35 +1,13 @@
import { StrictMode, useState, useEffect } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.tsx'; import App from './App.tsx';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import { UpdateNotification } from './components/UpdateNotification';
import * as swRegistration from './utils/serviceWorkerRegistration';
import './index.css'; import './index.css';
function Root() { createRoot(document.getElementById('root')!).render(
const [updateAvailable, setUpdateAvailable] = useState<ServiceWorkerRegistration | null>(null); <StrictMode>
<ErrorBoundary>
useEffect(() => { <App />
// Register service worker with update detection </ErrorBoundary>
swRegistration.register({ </StrictMode>
onUpdate: (registration) => { );
console.log('New version available!');
setUpdateAvailable(registration);
},
onSuccess: (registration) => {
console.log('Service worker registered successfully:', registration);
}
});
}, []);
return (
<StrictMode>
<ErrorBoundary>
<App />
<UpdateNotification registration={updateAvailable} />
</ErrorBoundary>
</StrictMode>
);
}
createRoot(document.getElementById('root')!).render(<Root />);

View File

@ -1,104 +0,0 @@
// Service Worker registration with automatic update detection
// This ensures users always get the latest version after deployment
type Config = {
onUpdate?: (registration: ServiceWorkerRegistration) => void;
onSuccess?: (registration: ServiceWorkerRegistration) => void;
};
export function register(config?: Config) {
if ('serviceWorker' in navigator) {
// Wait for page load to avoid impacting initial page load performance
window.addEventListener('load', () => {
const swUrl = `/sw.js`;
registerValidSW(swUrl, config);
// Check for updates every hour
setInterval(() => {
checkForUpdates(swUrl);
}, 60 * 60 * 1000); // 1 hour
});
}
}
function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
// Check for updates on initial registration
registration.update();
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// New content is available; please refresh
console.log('New content available! Please refresh.');
// Execute onUpdate callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// Content is cached for offline use
console.log('Content cached for offline use.');
// Execute onSuccess callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch((error) => {
console.error('Error during service worker registration:', error);
});
}
function checkForUpdates(swUrl: string) {
navigator.serviceWorker
.getRegistration(swUrl)
.then((registration) => {
if (registration) {
registration.update();
}
})
.catch((error) => {
console.error('Error checking for service worker updates:', error);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then((registration) => {
registration.unregister();
})
.catch((error) => {
console.error(error.message);
});
}
}
// Force refresh when a new service worker is waiting
export function skipWaitingAndReload() {
navigator.serviceWorker.ready.then((registration) => {
if (registration.waiting) {
// Tell the waiting service worker to skip waiting and become active
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
});
// Listen for the controller change and reload
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
});
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

View File

@ -1,124 +1,65 @@
// Service Worker with automatic versioning and cache invalidation const CACHE_NAME = 'puffin-calculator-v2'; // Bumped to clear old cached code
// Version is updated on each build to force cache refresh
const BUILD_TIMESTAMP = '__BUILD_TIMESTAMP__'; // Replaced during build
const CACHE_NAME = `puffin-calculator-${BUILD_TIMESTAMP}`;
const MAX_CACHE_AGE = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
const urlsToCache = [ const urlsToCache = [
'/', '/',
'/mobile-app', '/mobile-app',
'/static/js/bundle.js',
'/static/css/main.css',
'/puffinOffset.webp', '/puffinOffset.webp',
'/manifest.json' '/manifest.json'
]; ];
// Install event - cache resources // Install event - cache resources
self.addEventListener('install', (event) => { self.addEventListener('install', (event) => {
console.log('[SW] Installing service worker version:', BUILD_TIMESTAMP);
event.waitUntil( event.waitUntil(
caches.open(CACHE_NAME) caches.open(CACHE_NAME)
.then((cache) => { .then((cache) => {
console.log('[SW] Caching app shell');
return cache.addAll(urlsToCache); return cache.addAll(urlsToCache);
}) })
.then(() => {
// Force the waiting service worker to become the active service worker
return self.skipWaiting();
})
.catch((error) => { .catch((error) => {
console.error('[SW] Cache install failed:', error); console.log('Cache install failed:', error);
}) })
); );
}); });
// Activate event - clear old caches and claim clients // Activate event - clear old caches
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
console.log('[SW] Activating service worker version:', BUILD_TIMESTAMP);
event.waitUntil( event.waitUntil(
Promise.all([ caches.keys().then((cacheNames) => {
// Clear old caches return Promise.all(
caches.keys().then((cacheNames) => { cacheNames.map((cacheName) => {
return Promise.all( if (cacheName !== CACHE_NAME) {
cacheNames.map((cacheName) => { console.log('Clearing old cache:', cacheName);
if (cacheName !== CACHE_NAME) { return caches.delete(cacheName);
console.log('[SW] Clearing old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
}),
// Claim all clients immediately
self.clients.claim()
])
);
});
// Fetch event - Network first, fall back to cache
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip cross-origin requests
if (url.origin !== location.origin) {
return;
}
event.respondWith(
// Try network first
fetch(request)
.then((response) => {
// Don't cache if not a success response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
// Clone the response
const responseToCache = response.clone();
// Cache the fetched resource
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseToCache);
});
return response;
})
.catch(() => {
// Network failed, try cache
return caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
// Check cache age for HTML documents
if (request.destination === 'document') {
const cacheDate = cachedResponse.headers.get('sw-cache-date');
if (cacheDate) {
const age = Date.now() - parseInt(cacheDate, 10);
if (age > MAX_CACHE_AGE) {
console.log('[SW] Cached HTML is too old, returning without cache');
return new Response('Cache expired. Please connect to the internet.', {
status: 503,
statusText: 'Service Unavailable'
});
}
}
}
return cachedResponse;
} }
})
// Not in cache and network failed );
return new Response('Offline and not cached', { })
status: 503,
statusText: 'Service Unavailable'
});
});
})
); );
}); });
// Listen for skip waiting message // Fetch event - serve from cache when offline
self.addEventListener('message', (event) => { self.addEventListener('fetch', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') { event.respondWith(
console.log('[SW] Received SKIP_WAITING message'); caches.match(event.request)
self.skipWaiting(); .then((response) => {
} // Return cached version or fetch from network
return response || fetch(event.request);
}
)
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
}); });

View File

@ -1,56 +0,0 @@
#!/usr/bin/env node
/**
* Inject build timestamp into service worker
* This script replaces __BUILD_TIMESTAMP__ with the current timestamp
* to force cache invalidation on new deployments
*/
import fs from 'fs';
import path from 'path';
import { execFileSync } from 'child_process';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SW_SOURCE = path.join(__dirname, '..', 'public', 'sw.js');
try {
// Read the service worker file
let swContent = fs.readFileSync(SW_SOURCE, 'utf8');
// Check if placeholder exists
if (!swContent.includes('__BUILD_TIMESTAMP__')) {
console.log('⚠️ Service worker appears to be already processed');
process.exit(0);
}
// Generate version (use git commit hash if available, otherwise use timestamp)
let version;
try {
// Try to get git commit hash using safe execFileSync
version = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
}).trim();
console.log(`📦 Using git commit hash: ${version}`);
} catch (error) {
// Fallback to timestamp
version = Date.now().toString();
console.log(`📦 Using timestamp: ${version}`);
}
// Replace the placeholder with the version
swContent = swContent.replace(/__BUILD_TIMESTAMP__/g, version);
// Write the updated service worker
fs.writeFileSync(SW_SOURCE, swContent);
console.log(`✅ Service worker version injected: ${version}`);
console.log(`📝 Updated: ${SW_SOURCE}`);
} catch (error) {
console.error('❌ Error injecting service worker version:', error);
process.exit(1);
}

View File

@ -5,128 +5,9 @@ import { createWrenOffsetOrder, getWrenPortfolios } from '../utils/wrenClient.js
import { sendReceiptEmail, sendAdminNotification } from '../utils/emailService.js'; import { sendReceiptEmail, sendAdminNotification } from '../utils/emailService.js';
import { selectComparisons } from '../utils/carbonComparisons.js'; import { selectComparisons } from '../utils/carbonComparisons.js';
import { formatPortfolioProjects } from '../utils/portfolioColors.js'; import { formatPortfolioProjects } from '../utils/portfolioColors.js';
import { nocodbClient } from '../utils/nocodbClient.js';
import { mapStripeSessionToNocoDBOrder, mapWrenFulfillmentData } from '../utils/nocodbMapper.js';
const router = express.Router(); const router = express.Router();
/**
* Log all available Stripe session data for schema verification
* This helps us verify what fields are actually available from Stripe
*/
function logStripeSessionData(session) {
console.log('\n╔════════════════════════════════════════════════════════════════╗');
console.log('║ STRIPE CHECKOUT SESSION - COMPREHENSIVE DATA ║');
console.log('╚════════════════════════════════════════════════════════════════╝\n');
// Core Session Info
console.log('📋 SESSION INFORMATION:');
console.log(' Session ID:', session.id || 'N/A');
console.log(' Payment Intent:', session.payment_intent || 'N/A');
console.log(' Payment Status:', session.payment_status || 'N/A');
console.log(' Status:', session.status || 'N/A');
console.log(' Mode:', session.mode || 'N/A');
console.log(' Created:', session.created ? new Date(session.created * 1000).toISOString() : 'N/A');
console.log(' Expires At:', session.expires_at ? new Date(session.expires_at * 1000).toISOString() : 'N/A');
// Amount Details
console.log('\n💰 PAYMENT AMOUNT:');
console.log(' Amount Total:', session.amount_total ? `${session.amount_total} cents ($${(session.amount_total / 100).toFixed(2)})` : 'N/A');
console.log(' Amount Subtotal:', session.amount_subtotal ? `${session.amount_subtotal} cents ($${(session.amount_subtotal / 100).toFixed(2)})` : 'N/A');
console.log(' Currency:', session.currency ? session.currency.toUpperCase() : 'N/A');
// Customer Details
console.log('\n👤 CUSTOMER DETAILS:');
if (session.customer_details) {
console.log(' Name (Display):', session.customer_details.name || 'N/A');
console.log(' Business Name:', session.customer_details.business_name || 'N/A');
console.log(' Individual Name:', session.customer_details.individual_name || 'N/A');
console.log(' Email:', session.customer_details.email || 'N/A');
console.log(' Phone:', session.customer_details.phone || 'N/A');
console.log(' Tax Exempt:', session.customer_details.tax_exempt || 'N/A');
// Billing Address
console.log('\n📬 BILLING ADDRESS:');
if (session.customer_details.address) {
const addr = session.customer_details.address;
console.log(' Line 1:', addr.line1 || 'N/A');
console.log(' Line 2:', addr.line2 || 'N/A');
console.log(' City:', addr.city || 'N/A');
console.log(' State:', addr.state || 'N/A');
console.log(' Postal Code:', addr.postal_code || 'N/A');
console.log(' Country:', addr.country || 'N/A');
} else {
console.log(' No address provided');
}
} else {
console.log(' No customer details provided');
}
// Customer Object Reference
console.log('\n🔗 CUSTOMER OBJECT:');
console.log(' Customer ID:', session.customer || 'N/A');
// Payment Method Details
console.log('\n💳 PAYMENT METHOD:');
if (session.payment_method_types && session.payment_method_types.length > 0) {
console.log(' Types:', session.payment_method_types.join(', '));
} else {
console.log(' Types: N/A');
}
// Metadata (our custom data)
console.log('\n🏷 METADATA (Our Custom Fields):');
if (session.metadata && Object.keys(session.metadata).length > 0) {
Object.entries(session.metadata).forEach(([key, value]) => {
console.log(` ${key}:`, value);
});
} else {
console.log(' No metadata');
}
// Line Items (what they purchased)
console.log('\n🛒 LINE ITEMS:');
if (session.line_items) {
console.log(' Available in session object');
} else {
console.log(' Not expanded (need to fetch separately)');
}
// Additional Fields
console.log('\n🔧 ADDITIONAL FIELDS:');
console.log(' Client Reference ID:', session.client_reference_id || 'N/A');
console.log(' Locale:', session.locale || 'N/A');
console.log(' Success URL:', session.success_url || 'N/A');
console.log(' Cancel URL:', session.cancel_url || 'N/A');
// Shipping (if collected)
if (session.shipping) {
console.log('\n📦 SHIPPING (if collected):');
console.log(' Name:', session.shipping.name || 'N/A');
if (session.shipping.address) {
const addr = session.shipping.address;
console.log(' Address Line 1:', addr.line1 || 'N/A');
console.log(' Address Line 2:', addr.line2 || 'N/A');
console.log(' City:', addr.city || 'N/A');
console.log(' State:', addr.state || 'N/A');
console.log(' Postal Code:', addr.postal_code || 'N/A');
console.log(' Country:', addr.country || 'N/A');
}
}
// Tax IDs (if collected)
if (session.customer_details?.tax_ids && session.customer_details.tax_ids.length > 0) {
console.log('\n🆔 TAX IDS (if collected):');
session.customer_details.tax_ids.forEach((taxId, index) => {
console.log(` Tax ID ${index + 1}:`, taxId.type, '-', taxId.value);
});
}
console.log('\n╔════════════════════════════════════════════════════════════════╗');
console.log('║ END OF STRIPE DATA LOG ║');
console.log('╚════════════════════════════════════════════════════════════════╝\n');
}
/** /**
* POST /api/webhooks/stripe * POST /api/webhooks/stripe
* Handle Stripe webhook events * Handle Stripe webhook events
@ -148,17 +29,6 @@ router.post('/stripe', express.raw({ type: 'application/json' }), async (req, re
console.log(`📬 Received webhook: ${event.type}`); console.log(`📬 Received webhook: ${event.type}`);
// Log comprehensive Stripe data for schema verification
if (event.type === 'checkout.session.completed') {
logStripeSessionData(event.data.object);
}
// Log full webhook payload for debugging and data extraction
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('📦 Full Stripe Webhook Payload:');
console.log(JSON.stringify(event, null, 2));
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
// Handle different event types // Handle different event types
switch (event.type) { switch (event.type) {
case 'checkout.session.completed': case 'checkout.session.completed':
@ -222,11 +92,8 @@ async function handleCheckoutSessionCompleted(session) {
} }
console.log(`💳 Payment confirmed for order: ${order.id}`); console.log(`💳 Payment confirmed for order: ${order.id}`);
console.log(` Customer: ${session.customer_details?.email}`);
console.log(` Amount: $${(order.total_amount / 100).toFixed(2)}`); console.log(` Amount: $${(order.total_amount / 100).toFixed(2)}`);
// (Detailed session data already logged above via logStripeSessionData())
// Save order to NocoDB
await saveOrderToNocoDB(order, session);
// Fulfill order via Wren API // Fulfill order via Wren API
await fulfillOrder(order, session); await fulfillOrder(order, session);
@ -377,9 +244,6 @@ async function fulfillOrder(order, session) {
console.log(` Wren Order ID: ${wrenOrder.id}`); console.log(` Wren Order ID: ${wrenOrder.id}`);
console.log(` Tons offset: ${order.tons}`); console.log(` Tons offset: ${order.tons}`);
// Update NocoDB with fulfillment data
await updateNocoDBFulfillment(order.id, wrenOrder);
// Send receipt email to customer // Send receipt email to customer
const customerEmail = session.customer_details?.email || order.customer_email; const customerEmail = session.customer_details?.email || order.customer_email;
if (customerEmail) { if (customerEmail) {
@ -453,75 +317,4 @@ async function fulfillOrder(order, session) {
} }
} }
/**
* Save order to NocoDB
* @param {Object} order - Local database order object
* @param {Object} session - Stripe session object
*/
async function saveOrderToNocoDB(order, session) {
// Skip if NocoDB is not configured
if (!nocodbClient.isConfigured()) {
console.log(' NocoDB not configured, skipping database save');
return;
}
try {
console.log(`💾 Saving order ${order.id} to NocoDB...`);
// Map Stripe session data to NocoDB format
const nocodbOrderData = mapStripeSessionToNocoDBOrder(session, order);
// Create record in NocoDB
const response = await nocodbClient.createOrder(nocodbOrderData);
console.log(`✅ Order saved to NocoDB successfully`);
console.log(` NocoDB Record ID: ${response.Id}`);
console.log(` Order ID: ${nocodbOrderData.orderId}`);
console.log(` Customer: ${nocodbOrderData.customerName} (${nocodbOrderData.customerEmail})`);
if (nocodbOrderData.businessName) {
console.log(` Business: ${nocodbOrderData.businessName}`);
}
if (nocodbOrderData.taxIdType && nocodbOrderData.taxIdValue) {
console.log(` Tax ID: ${nocodbOrderData.taxIdType} - ${nocodbOrderData.taxIdValue}`);
}
} catch (error) {
console.error('❌ Failed to save order to NocoDB:', error.message);
console.error(' This is non-fatal - order is still saved locally');
// Don't throw - we don't want to fail the webhook if NocoDB is down
}
}
/**
* Update NocoDB with fulfillment data
* @param {string} orderId - Order ID
* @param {Object} wrenOrder - Wren order response
*/
async function updateNocoDBFulfillment(orderId, wrenOrder) {
// Skip if NocoDB is not configured
if (!nocodbClient.isConfigured()) {
return;
}
try {
console.log(`💾 Updating NocoDB with fulfillment data for order ${orderId}...`);
// Map Wren fulfillment data
const fulfillmentData = mapWrenFulfillmentData(orderId, wrenOrder);
// Update NocoDB record
await nocodbClient.updateOrderFulfillment(orderId, fulfillmentData);
console.log(`✅ NocoDB updated with fulfillment data`);
console.log(` Wren Order ID: ${fulfillmentData.wrenOrderId}`);
console.log(` Status: ${fulfillmentData.status}`);
} catch (error) {
console.error('❌ Failed to update NocoDB with fulfillment:', error.message);
// Non-fatal - don't throw
}
}
export default router; export default router;

View File

@ -151,14 +151,7 @@ export async function sendContactEmail(contactData) {
// Send admin notification for new order // Send admin notification for new order
export async function sendAdminNotification(orderDetails, customerEmail) { export async function sendAdminNotification(orderDetails, customerEmail) {
// totalAmount is already in dollars (converted before calling this function) const subject = `New Order: ${orderDetails.tons} tons CO₂ - $${(orderDetails.totalAmount / 100).toFixed(2)}`;
const totalAmount = typeof orderDetails.totalAmount === 'string'
? orderDetails.totalAmount
: (orderDetails.totalAmount / 100).toFixed(2);
// Format amount with commas for subject line
const formattedAmount = parseFloat(totalAmount).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
const subject = `New Order: ${orderDetails.tons} tons CO₂ - $${formattedAmount}`;
const adminEmail = process.env.ADMIN_EMAIL || 'matt@puffinoffset.com'; const adminEmail = process.env.ADMIN_EMAIL || 'matt@puffinoffset.com';
// Check if admin notifications are enabled // Check if admin notifications are enabled
@ -174,7 +167,7 @@ export async function sendAdminNotification(orderDetails, customerEmail) {
{ {
tons: orderDetails.tons, tons: orderDetails.tons,
portfolioId: orderDetails.portfolioId, portfolioId: orderDetails.portfolioId,
totalAmount: totalAmount, totalAmount: (orderDetails.totalAmount / 100).toFixed(2),
orderId: orderDetails.orderId, orderId: orderDetails.orderId,
customerEmail, customerEmail,
timestamp: new Date().toLocaleString('en-US', { timestamp: new Date().toLocaleString('en-US', {

View File

@ -1,117 +0,0 @@
/**
* NocoDB Client for Server
* Simple REST API client for NocoDB operations
*/
class NocoDBClient {
constructor() {
this.config = {
baseUrl: process.env.NOCODB_BASE_URL || '',
baseId: process.env.NOCODB_BASE_ID || '',
apiKey: process.env.NOCODB_API_KEY || '',
ordersTableId: process.env.NOCODB_ORDERS_TABLE_ID || '',
};
// Check configuration completeness
const missingVars = [];
if (!this.config.baseUrl) missingVars.push('NOCODB_BASE_URL');
if (!this.config.baseId) missingVars.push('NOCODB_BASE_ID');
if (!this.config.apiKey) missingVars.push('NOCODB_API_KEY');
if (!this.config.ordersTableId) missingVars.push('NOCODB_ORDERS_TABLE_ID');
if (missingVars.length > 0) {
console.warn('⚠️ NocoDB configuration incomplete. Missing variables:', missingVars.join(', '));
console.warn(' Database integration will be disabled.');
} else {
console.log('✅ NocoDB client initialized successfully');
console.log(` Base URL: ${this.config.baseUrl}`);
console.log(` Table ID: ${this.config.ordersTableId}`);
}
this.baseUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records`;
}
/**
* Make authenticated request to NocoDB
*/
async request(endpoint, options = {}) {
const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'xc-token': this.config.apiKey,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`NocoDB request failed: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Create new order record
*/
async createOrder(orderData) {
return this.request('', {
method: 'POST',
body: JSON.stringify(orderData),
});
}
/**
* Update order fields
* NocoDB v2 API requires the ID in the body, not the URL
*/
async updateOrder(recordId, data) {
return this.request('', {
method: 'PATCH',
body: JSON.stringify({
Id: recordId,
...data,
}),
});
}
/**
* Find order by orderId field
*/
async findOrderByOrderId(orderId) {
const params = new URLSearchParams();
params.append('where', `(orderId,eq,${orderId})`);
params.append('limit', '1');
const response = await this.request(`?${params.toString()}`);
return response.list?.[0] || null;
}
/**
* Update order with Wren fulfillment data
*/
async updateOrderFulfillment(orderId, fulfillmentData) {
// First find the NocoDB record ID
const order = await this.findOrderByOrderId(orderId);
if (!order) {
throw new Error(`Order not found in NocoDB: ${orderId}`);
}
// Update the record
return this.updateOrder(order.Id, fulfillmentData);
}
/**
* Check if NocoDB is configured
*/
isConfigured() {
return !!(this.config.baseUrl && this.config.baseId && this.config.apiKey && this.config.ordersTableId);
}
}
// Export singleton instance
export const nocodbClient = new NocoDBClient();

View File

@ -1,133 +0,0 @@
/**
* NocoDB Order Mapper
* Maps Stripe Checkout Session data to NocoDB OrderRecord format
*/
/**
* Map Stripe session data to NocoDB OrderRecord format
* @param {Object} session - Stripe checkout session object
* @param {Object} order - Local database order object
* @returns {Object} NocoDB-compatible order record
*/
export function mapStripeSessionToNocoDBOrder(session, order) {
const metadata = session.metadata || {};
const customerDetails = session.customer_details || {};
const address = customerDetails.address || {};
const taxIds = customerDetails.tax_ids || [];
return {
// Order identification
orderId: order.id,
status: 'paid', // Will be updated to 'fulfilled' after Wren order
source: determineOrderSource(session),
// Payment information
stripeSessionId: session.id,
stripePaymentIntent: session.payment_intent || null,
stripeCustomerId: session.customer || null,
baseAmount: metadata.baseAmount || order.base_amount?.toString() || '0',
processingFee: metadata.processingFee || order.processing_fee?.toString() || '0',
totalAmount: session.amount_total?.toString() || order.total_amount?.toString() || '0',
currency: session.currency?.toUpperCase() || order.currency || 'USD',
amountUSD: calculateUSDAmount(session.amount_total, session.currency),
paymentMethod: session.payment_method_types?.[0] || null,
// Carbon offset details
co2Tons: metadata.tons || order.tons?.toString() || '0',
portfolioId: metadata.portfolioId || order.portfolio_id?.toString() || '',
portfolioName: null, // Will be populated later from Wren API
wrenOrderId: null, // Will be populated after fulfillment
certificateUrl: null, // Will be populated after fulfillment
fulfilledAt: null, // Will be set when order is fulfilled
// Customer information
customerName: customerDetails.name || 'Unknown',
customerEmail: customerDetails.email || order.customer_email || '',
customerPhone: customerDetails.phone || null,
businessName: customerDetails.business_name || null,
taxIdType: taxIds[0]?.type || null,
taxIdValue: taxIds[0]?.value || null,
// Billing address
billingLine1: address.line1 || null,
billingLine2: address.line2 || null,
billingCity: address.city || null,
billingState: address.state || null,
billingPostalCode: address.postal_code || null,
billingCountry: address.country || null,
// Vessel information (from metadata or order, if available)
vesselName: metadata.vesselName || order.vessel_name || null,
imoNumber: metadata.imoNumber || order.imo_number || null,
vesselType: metadata.vesselType || null,
vesselLength: metadata.vesselLength || null,
// Trip details (from metadata, if available)
departurePort: metadata.departurePort || null,
arrivalPort: metadata.arrivalPort || null,
distance: metadata.distance || null,
avgSpeed: metadata.avgSpeed || null,
duration: metadata.duration || null,
enginePower: metadata.enginePower || null,
// Administrative
notes: null,
};
}
/**
* Determine order source from session data
* @param {Object} session - Stripe checkout session object
* @returns {string} Order source
*/
function determineOrderSource(session) {
const metadata = session.metadata || {};
// Check if source is specified in metadata
if (metadata.source) {
return metadata.source;
}
// Check success URL for mobile app indicator
if (session.success_url?.includes('mobile-app')) {
return 'mobile-app';
}
// Default to web
return 'web';
}
/**
* Calculate USD amount for reporting
* @param {number} amount - Amount in cents
* @param {string} currency - Currency code
* @returns {string} USD amount in cents
*/
function calculateUSDAmount(amount, currency) {
if (!amount) return '0';
// If already USD, return as-is
if (currency?.toLowerCase() === 'usd') {
return amount.toString();
}
// TODO: Implement currency conversion using real-time rates
// For now, return the original amount
// In production, fetch rates from an API or use a conversion service
return amount.toString();
}
/**
* Update NocoDB order with Wren fulfillment data
* @param {string} orderId - Order ID
* @param {Object} wrenOrder - Wren API order response
* @returns {Object} Update data for NocoDB
*/
export function mapWrenFulfillmentData(orderId, wrenOrder) {
return {
wrenOrderId: wrenOrder.id,
certificateUrl: wrenOrder.certificate_url || null,
fulfilledAt: new Date().toISOString(),
status: 'fulfilled',
};
}

View File

@ -166,6 +166,27 @@ export async function getWrenPortfolios() {
console.log('✅ [WREN API SERVER] Status:', response.status); console.log('✅ [WREN API SERVER] Status:', response.status);
console.log('✅ [WREN API SERVER] Duration:', duration + 'ms'); console.log('✅ [WREN API SERVER] Duration:', duration + 'ms');
console.log('✅ [WREN API SERVER] Portfolios count:', response.data?.portfolios?.length || 0); console.log('✅ [WREN API SERVER] Portfolios count:', response.data?.portfolios?.length || 0);
// Log detailed portfolio and project information including certification status
if (response.data?.portfolios?.length > 0) {
console.log('📊 [WREN API SERVER] Portfolio Details:');
response.data.portfolios.forEach((portfolio, idx) => {
console.log(` Portfolio ${idx + 1}: "${portfolio.name}" (ID: ${portfolio.id})`);
console.log(` ├─ Cost per ton: $${portfolio.cost_per_ton}`);
console.log(` └─ Projects (${portfolio.projects?.length || 0}):`);
if (portfolio.projects?.length > 0) {
portfolio.projects.forEach((project, pIdx) => {
console.log(` ${pIdx + 1}. "${project.name}"`);
console.log(` ├─ Certification: ${project.certification_status || 'N/A'}`);
console.log(` ├─ Cost per ton: $${project.cost_per_ton || 'N/A'}`);
console.log(` └─ Percentage: ${project.percentage ? (project.percentage * 100).toFixed(1) + '%' : 'N/A'}`);
});
}
console.log('');
});
}
console.log('🔵 [WREN API SERVER] ========================================'); console.log('🔵 [WREN API SERVER] ========================================');
return response.data?.portfolios || []; return response.data?.portfolios || [];

469
src/App.tsx Normal file
View File

@ -0,0 +1,469 @@
import React, { useState, useEffect } from 'react';
import { Menu, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Home } from './components/Home';
import { YachtSearch } from './components/YachtSearch';
import { TripCalculator } from './components/TripCalculator';
import { MobileCalculator } from './components/MobileCalculator';
import { HowItWorks } from './components/HowItWorks';
import { About } from './components/About';
import { Contact } from './components/Contact';
import { OffsetOrder } from './components/OffsetOrder';
import CheckoutSuccess from './pages/CheckoutSuccess';
import CheckoutCancel from './pages/CheckoutCancel';
import { getVesselData } from './api/aisClient';
import { calculateTripCarbon } from './utils/carbonCalculator';
import { analytics } from './utils/analytics';
import { useCalculatorState } from './hooks/useCalculatorState';
import type { VesselData, CarbonCalculation, CalculatorType } from './types';
const sampleVessel: VesselData = {
imo: "1234567",
vesselName: "Sample Yacht",
type: "Yacht",
length: 50,
width: 9,
estimatedEnginePower: 2250
};
function App() {
const { state: savedState, saveState } = useCalculatorState();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [vesselData, setVesselData] = useState<VesselData | null>(null);
const [currentPage, setCurrentPage] = useState<'home' | 'calculator' | 'how-it-works' | 'about' | 'contact'>('home');
const [showOffsetOrder, setShowOffsetOrder] = useState(savedState?.showOffsetOrder || false);
const [offsetTons, setOffsetTons] = useState(savedState?.offsetTons || 0);
const [monetaryAmount, setMonetaryAmount] = useState<number | undefined>(savedState?.monetaryAmount);
const [calculatorType, setCalculatorType] = useState<CalculatorType>(savedState?.calculatorTypeUsed || 'trip');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isMobileApp, setIsMobileApp] = useState(false);
const [isCheckoutSuccess, setIsCheckoutSuccess] = useState(false);
const [isCheckoutCancel, setIsCheckoutCancel] = useState(false);
const [showHeader, setShowHeader] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);
useEffect(() => {
// Check if we're on special routes
const path = window.location.pathname;
setIsMobileApp(path === '/mobile-app');
setIsCheckoutSuccess(path === '/checkout/success');
setIsCheckoutCancel(path === '/checkout/cancel');
analytics.pageView(path);
}, [currentPage]);
useEffect(() => {
// Handle URL changes (for back/forward navigation)
const handlePopState = () => {
const path = window.location.pathname;
setIsMobileApp(path === '/mobile-app');
setIsCheckoutSuccess(path === '/checkout/success');
setIsCheckoutCancel(path === '/checkout/cancel');
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
useEffect(() => {
// Hide header on scroll down, show on scroll up
const handleScroll = () => {
const currentScrollY = window.scrollY;
// Always show header at the top of the page
if (currentScrollY < 10) {
setShowHeader(true);
} else if (currentScrollY > lastScrollY) {
// Scrolling down
setShowHeader(false);
} else {
// Scrolling up
setShowHeader(true);
}
setLastScrollY(currentScrollY);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [lastScrollY]);
// Restore offset order state when navigating to calculator page
// This handles browser back from Stripe checkout
useEffect(() => {
console.log('[State Restoration Debug] useEffect triggered');
console.log('[State Restoration Debug] currentPage:', currentPage);
console.log('[State Restoration Debug] savedState:', savedState);
console.log('[State Restoration Debug] savedState?.showOffsetOrder:', savedState?.showOffsetOrder);
if (currentPage === 'calculator' && savedState && savedState.showOffsetOrder) {
console.log('[State Restoration] ✅ Conditions met - Restoring offset order state from localStorage');
console.log('[State Restoration] Offset tons:', savedState.offsetTons);
console.log('[State Restoration] Monetary amount:', savedState.monetaryAmount);
console.log('[State Restoration] Calculator type:', savedState.calculatorTypeUsed);
setShowOffsetOrder(true);
setOffsetTons(savedState.offsetTons || 0);
setMonetaryAmount(savedState.monetaryAmount);
setCalculatorType(savedState.calculatorTypeUsed || 'trip');
} else {
console.log('[State Restoration] ❌ Conditions NOT met - State will NOT be restored');
if (currentPage !== 'calculator') {
console.log('[State Restoration] Reason: currentPage is not "calculator"');
}
if (!savedState) {
console.log('[State Restoration] Reason: savedState is null/undefined');
}
if (savedState && !savedState.showOffsetOrder) {
console.log('[State Restoration] Reason: showOffsetOrder is false');
}
}
}, [currentPage, savedState]);
const handleSearch = async (imo: string) => {
setLoading(true);
setError(null);
setVesselData(null);
try {
const vessel = await getVesselData(imo);
setVesselData(vessel);
} catch (err) {
setError('Unable to fetch vessel data. Please verify the IMO number and try again.');
} finally {
setLoading(false);
}
};
const handleOffsetClick = (tons: number, monetaryAmount?: number) => {
console.log('[Offset Click Debug] handleOffsetClick called with tons:', tons, 'amount:', monetaryAmount);
setOffsetTons(tons);
setMonetaryAmount(monetaryAmount);
setShowOffsetOrder(true);
// Save offset state to localStorage for browser back navigation
const stateToSave = {
showOffsetOrder: true,
offsetTons: tons,
monetaryAmount,
calculatorTypeUsed: calculatorType,
};
console.log('[Offset Click Debug] Saving state to localStorage:', stateToSave);
saveState(stateToSave);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleNavigate = (page: 'home' | 'calculator' | 'how-it-works' | 'about' | 'contact') => {
console.log('[Navigation Debug] handleNavigate called with page:', page);
setCurrentPage(page);
setMobileMenuOpen(false);
setIsMobileApp(false);
setIsCheckoutSuccess(false); // Clear checkout flags when navigating
setIsCheckoutCancel(false); // Clear checkout flags when navigating
window.history.pushState({}, '', `/${page === 'home' ? '' : page}`);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const handleBackFromMobile = () => {
setIsMobileApp(false);
window.history.pushState({}, '', '/');
setCurrentPage('home');
};
const renderPage = () => {
if (currentPage === 'calculator' && showOffsetOrder) {
return (
<div className="flex justify-center px-4 sm:px-0">
<OffsetOrder
tons={offsetTons}
monetaryAmount={monetaryAmount}
onBack={() => {
setShowOffsetOrder(false);
// Clear offset state from localStorage when going back
saveState({
showOffsetOrder: false,
offsetTons: 0,
monetaryAmount: undefined,
});
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
calculatorType={calculatorType}
/>
</div>
);
}
switch (currentPage) {
case 'calculator':
return (
<div className="flex flex-col items-center">
<div className="text-center mb-12 max-w-4xl">
<h2 className="text-3xl sm:text-4xl font-bold text-gray-900 mb-4">
Calculate & Offset Your Yacht's Carbon Footprint
</h2>
<p className="text-base sm:text-lg text-gray-600">
Use the calculator below to estimate your carbon footprint and explore offsetting options through our verified projects.
</p>
</div>
<div className="flex flex-col items-center w-full max-w-4xl space-y-8">
<TripCalculator
vesselData={sampleVessel}
onOffsetClick={handleOffsetClick}
/>
</div>
</div>
);
case 'how-it-works':
return <HowItWorks onNavigate={handleNavigate} />;
case 'about':
return <About onNavigate={handleNavigate} />;
case 'contact':
return <Contact />;
default:
return <Home onNavigate={handleNavigate} />;
}
};
// If we're on the mobile app route, render only the mobile calculator
if (isMobileApp) {
return (
<MobileCalculator
vesselData={sampleVessel}
onOffsetClick={handleOffsetClick}
onBack={handleBackFromMobile}
/>
);
}
// If we're on the checkout success route, render only the success page
if (isCheckoutSuccess) {
return (
<CheckoutSuccess
onNavigateHome={() => handleNavigate('home')}
onNavigateCalculator={() => handleNavigate('calculator')}
/>
);
}
// If we're on the checkout cancel route, render only the cancel page
if (isCheckoutCancel) {
return (
<CheckoutCancel
onNavigateHome={() => handleNavigate('home')}
onNavigateCalculator={() => handleNavigate('calculator')}
/>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 wave-pattern">
<header
className="glass-nav shadow-luxury z-50 fixed top-0 left-0 right-0 transition-transform duration-300 ease-in-out"
style={{
transform: showHeader ? 'translate3d(0,0,0)' : 'translate3d(0,-100%,0)',
WebkitTransform: showHeader ? 'translate3d(0,0,0)' : 'translate3d(0,-100%,0)',
WebkitBackfaceVisibility: 'hidden',
backfaceVisibility: 'hidden'
}}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<motion.div
className="flex items-center space-x-3 cursor-pointer group"
onClick={() => handleNavigate('home')}
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<motion.img
src="/puffinOffset.webp"
alt="Puffin Offset Logo"
className="h-10 w-auto transition-transform duration-300 group-hover:scale-110"
initial={{ opacity: 0, rotate: -10 }}
animate={{ opacity: 1, rotate: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
/>
<motion.h1
className="text-xl font-bold heading-luxury"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
Puffin Offset
</motion.h1>
</motion.div>
{/* Mobile menu button */}
<button
className="sm:hidden p-2 rounded-md text-gray-600 hover:text-gray-900"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
</button>
{/* Desktop navigation */}
<nav className="hidden sm:flex space-x-2">
{['calculator', 'how-it-works', 'about', 'contact'].map((page, index) => (
<motion.button
key={page}
onClick={() => handleNavigate(page as any)}
className={`px-4 py-2 rounded-xl font-medium transition-all duration-300 ${
currentPage === page
? 'bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg'
: 'text-slate-600 hover:text-slate-900 hover:bg-white/60'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.6 + index * 0.1 }}
>
{page === 'how-it-works' ? 'How it Works' :
page.charAt(0).toUpperCase() + page.slice(1)}
</motion.button>
))}
</nav>
</div>
{/* Mobile navigation */}
{mobileMenuOpen && (
<nav className="sm:hidden mt-4 pb-2 space-y-2">
<button
onClick={() => handleNavigate('calculator')}
className={`block w-full text-left px-4 py-2 rounded-lg ${
currentPage === 'calculator'
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
Calculator
</button>
<button
onClick={() => handleNavigate('how-it-works')}
className={`block w-full text-left px-4 py-2 rounded-lg ${
currentPage === 'how-it-works'
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
How it Works
</button>
<button
onClick={() => handleNavigate('about')}
className={`block w-full text-left px-4 py-2 rounded-lg ${
currentPage === 'about'
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
About
</button>
<button
onClick={() => handleNavigate('contact')}
className={`block w-full text-left px-4 py-2 rounded-lg ${
currentPage === 'contact'
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
Contact
</button>
</nav>
)}
</div>
</header>
<main className="max-w-[1600px] mx-auto pt-24 pb-8 sm:pb-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={currentPage + (showOffsetOrder ? '-offset' : '')}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{
duration: 0.4,
ease: [0.25, 0.1, 0.25, 1.0]
}}
>
{renderPage()}
</motion.div>
</AnimatePresence>
</main>
<footer className="bg-gradient-to-r from-slate-900 via-blue-900 to-slate-900 mt-16 relative overflow-hidden">
<div className="absolute inset-0 bg-[url('data:image/svg+xml,%3csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3e%3cg fill='none' fill-rule='evenodd'%3e%3cg fill='%23ffffff' fill-opacity='0.03'%3e%3cpath d='m36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3e%3c/g%3e%3c/g%3e%3c/svg%3e')] opacity-20"></div>
<div className="relative max-w-7xl mx-auto px-4 py-12 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
viewport={{ once: true }}
>
<div className="flex items-center space-x-3 mb-4">
<img
src="/puffinOffset.webp"
alt="Puffin Offset Logo"
className="h-8 w-auto"
/>
<h3 className="text-xl font-bold text-white">Puffin Offset</h3>
</div>
<p className="text-slate-300 leading-relaxed">
The world's most exclusive carbon offsetting platform for superyacht owners and operators.
</p>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
viewport={{ once: true }}
className="text-center md:text-left"
>
<h4 className="text-lg font-semibold text-white mb-4">Services</h4>
<ul className="space-y-2 text-slate-300">
<li className="hover:text-yellow-400 transition-colors cursor-pointer">Carbon Calculator</li>
<li className="hover:text-yellow-400 transition-colors cursor-pointer">Offset Portfolio</li>
<li className="hover:text-yellow-400 transition-colors cursor-pointer">Fleet Management</li>
<li className="hover:text-yellow-400 transition-colors cursor-pointer">Custom Solutions</li>
</ul>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
viewport={{ once: true }}
className="text-center md:text-left"
>
<h4 className="text-lg font-semibold text-white mb-4">Sustainability Partners</h4>
<p className="text-slate-300 mb-4">
Powered by verified carbon offset projects through Wren Climate
</p>
<div className="text-xs text-slate-400">
All projects are verified to international standards
</div>
</motion.div>
</div>
<motion.div
className="border-t border-slate-700 pt-8 text-center"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.6 }}
viewport={{ once: true }}
>
<p className="text-slate-400">
© 2024 Puffin Offset. Luxury meets sustainability.
</p>
</motion.div>
</div>
</footer>
</div>
);
}
export default App;

View File

@ -8,8 +8,7 @@ const getApiKey = (): string | undefined => {
if (typeof window !== 'undefined' && window.env?.MARINE_TRAFFIC_API_KEY) { if (typeof window !== 'undefined' && window.env?.MARINE_TRAFFIC_API_KEY) {
return window.env.MARINE_TRAFFIC_API_KEY; return window.env.MARINE_TRAFFIC_API_KEY;
} }
// Next.js requires direct static reference to NEXT_PUBLIC_ variables return import.meta.env.VITE_MARINE_TRAFFIC_API_KEY;
return process.env.NEXT_PUBLIC_MARINE_TRAFFIC_API_KEY;
}; };
export async function getVesselData(imo: string): Promise<VesselData> { export async function getVesselData(imo: string): Promise<VesselData> {

View File

@ -1,18 +1,17 @@
import axios from 'axios'; import axios from 'axios';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
// Get API base URL from runtime config (window.env) // Get API base URL from runtime config (window.env) or build-time config
// IMPORTANT: Call this function at REQUEST TIME, not at module load time, // IMPORTANT: Call this function at REQUEST TIME, not at module load time,
// to ensure window.env is populated by env-config.js // to ensure window.env is populated by env-config.js
const getApiBaseUrl = (): string => { const getApiBaseUrl = (): string => {
// Check window.env first (runtime config from env.sh or docker-compose) // Check window.env first (runtime config from env.sh)
if (typeof window !== 'undefined' && window.env?.API_BASE_URL) { if (typeof window !== 'undefined' && window.env?.API_BASE_URL) {
return window.env.API_BASE_URL; return window.env.API_BASE_URL;
} }
// Fall back to production default if not configured // Fall back to build-time env or production default
// All configuration is now runtime-only via environment variables return import.meta.env.VITE_API_BASE_URL || 'https://puffinoffset.com/api';
return 'https://puffinoffset.com/api';
}; };
export interface CreateCheckoutSessionParams { export interface CreateCheckoutSessionParams {
@ -39,7 +38,6 @@ export interface OrderDetails {
currency: string; currency: string;
status: string; status: string;
wrenOrderId: string | null; wrenOrderId: string | null;
stripeSessionId: string;
createdAt: string; createdAt: string;
}; };
session: { session: {

View File

@ -1,18 +1,17 @@
import axios from 'axios'; import axios from 'axios';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
// Get API base URL from runtime config (window.env) // Get API base URL from runtime config (window.env) or build-time config
// IMPORTANT: Call this function at REQUEST TIME, not at module load time, // IMPORTANT: Call this function at REQUEST TIME, not at module load time,
// to ensure window.env is populated by env-config.js // to ensure window.env is populated by env-config.js
const getApiBaseUrl = (): string => { const getApiBaseUrl = (): string => {
// Check window.env first (runtime config from env.sh or docker-compose) // Check window.env first (runtime config from env.sh)
if (typeof window !== 'undefined' && window.env?.API_BASE_URL) { if (typeof window !== 'undefined' && window.env?.API_BASE_URL) {
return window.env.API_BASE_URL; return window.env.API_BASE_URL;
} }
// Fall back to production default if not configured // Fall back to build-time env or production default
// All configuration is now runtime-only via environment variables return import.meta.env.VITE_API_BASE_URL || 'https://puffinoffset.com/api';
return 'https://puffinoffset.com/api';
}; };
export interface ContactEmailData { export interface ContactEmailData {

View File

@ -1,348 +0,0 @@
/**
* NocoDB Client - Clean abstraction layer for NocoDB REST API
* Hides query syntax complexity behind simple TypeScript functions
*/
interface NocoDBConfig {
baseUrl: string;
baseId: string;
apiKey: string;
ordersTableId: string;
}
interface OrderFilters {
status?: string;
vesselName?: string;
imoNumber?: string;
dateFrom?: string;
dateTo?: string;
minAmount?: number;
maxAmount?: number;
}
interface PaginationParams {
limit?: number;
offset?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
interface NocoDBResponse<T> {
list: T[];
pageInfo: {
totalRows: number;
page: number;
pageSize: number;
isFirstPage: boolean;
isLastPage: boolean;
};
}
interface OrderStats {
totalOrders: number;
totalRevenue: number;
totalCO2Offset: number;
fulfillmentRate: number;
ordersByStatus: {
pending: number;
paid: number;
fulfilled: number;
cancelled: number;
};
}
export class NocoDBClient {
private config: NocoDBConfig;
private baseUrl: string;
constructor() {
this.config = {
baseUrl: process.env.NOCODB_BASE_URL || '',
baseId: process.env.NOCODB_BASE_ID || '',
apiKey: process.env.NOCODB_API_KEY || '',
ordersTableId: process.env.NOCODB_ORDERS_TABLE_ID || '',
};
if (!this.config.baseUrl || !this.config.baseId || !this.config.apiKey || !this.config.ordersTableId) {
console.warn('NocoDB configuration incomplete. Some features may not work.');
}
this.baseUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records`;
}
/**
* Build NocoDB where clause from filters
*/
private buildWhereClause(filters: OrderFilters): string {
const conditions: string[] = [];
if (filters.status) {
conditions.push(`(status,eq,${filters.status})`);
}
if (filters.vesselName) {
conditions.push(`(vesselName,like,%${filters.vesselName}%)`);
}
if (filters.imoNumber) {
conditions.push(`(imoNumber,eq,${filters.imoNumber})`);
}
if (filters.dateFrom) {
// Convert YYYY-MM-DD to ISO timestamp at start of day
const dateFromTimestamp = `${filters.dateFrom} 00:00:00`;
conditions.push(`(CreatedAt,gte,${dateFromTimestamp})`);
}
if (filters.dateTo) {
// Convert YYYY-MM-DD to ISO timestamp at end of day
const dateToTimestamp = `${filters.dateTo} 23:59:59`;
conditions.push(`(CreatedAt,lte,${dateToTimestamp})`);
}
if (filters.minAmount !== undefined) {
conditions.push(`(totalAmount,gte,${filters.minAmount})`);
}
if (filters.maxAmount !== undefined) {
conditions.push(`(totalAmount,lte,${filters.maxAmount})`);
}
return conditions.length > 0 ? conditions.join('~and') : '';
}
/**
* Build sort parameter
*/
private buildSortParam(sortBy?: string, sortOrder?: 'asc' | 'desc'): string {
if (!sortBy) return '-CreatedAt'; // Default: newest first
const prefix = sortOrder === 'asc' ? '' : '-';
return `${prefix}${sortBy}`;
}
/**
* Make authenticated request to NocoDB
*/
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'xc-token': this.config.apiKey,
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`NocoDB request failed: ${response.status} - ${errorText}`);
}
return response.json();
}
/**
* Get list of orders with filtering, sorting, and pagination
*/
async getOrders(
filters: OrderFilters = {},
pagination: PaginationParams = {}
): Promise<NocoDBResponse<any>> {
const params = new URLSearchParams();
// Add where clause if filters exist
const whereClause = this.buildWhereClause(filters);
if (whereClause) {
params.append('where', whereClause);
}
// Add sorting
const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder);
params.append('sort', sort);
// Add pagination
if (pagination.limit) {
params.append('limit', pagination.limit.toString());
}
if (pagination.offset) {
params.append('offset', pagination.offset.toString());
}
const queryString = params.toString();
const endpoint = queryString ? `?${queryString}` : '';
return this.request<NocoDBResponse<any>>(endpoint);
}
/**
* Get single order by ID
*/
async getOrderById(recordId: string): Promise<any> {
return this.request<any>(`/${recordId}`);
}
/**
* Search orders by text (searches vessel name, IMO, order ID)
*/
async searchOrders(
searchTerm: string,
pagination: PaginationParams = {}
): Promise<NocoDBResponse<any>> {
// Search in multiple fields using OR conditions
const searchConditions = [
`(vesselName,like,%${searchTerm}%)`,
`(imoNumber,like,%${searchTerm}%)`,
`(orderId,like,%${searchTerm}%)`,
].join('~or');
const params = new URLSearchParams();
params.append('where', searchConditions);
const sort = this.buildSortParam(pagination.sortBy, pagination.sortOrder);
params.append('sort', sort);
if (pagination.limit) {
params.append('limit', pagination.limit.toString());
}
if (pagination.offset) {
params.append('offset', pagination.offset.toString());
}
return this.request<NocoDBResponse<any>>(`?${params.toString()}`);
}
/**
* Get order statistics for dashboard
*/
async getStats(dateFrom?: string, dateTo?: string): Promise<OrderStats> {
const filters: OrderFilters = {};
if (dateFrom) filters.dateFrom = dateFrom;
if (dateTo) filters.dateTo = dateTo;
// Get all orders with filters (no pagination for stats calculation)
const response = await this.getOrders(filters, { limit: 10000 });
const orders = response.list;
// Calculate stats
const totalOrders = orders.length;
const totalRevenue = orders.reduce((sum, order) => sum + (order.totalAmount || 0), 0);
const totalCO2Offset = orders.reduce((sum, order) => sum + (order.co2Tons || 0), 0);
const ordersByStatus = {
pending: orders.filter((o) => o.status === 'pending').length,
paid: orders.filter((o) => o.status === 'paid').length,
fulfilled: orders.filter((o) => o.status === 'fulfilled').length,
cancelled: orders.filter((o) => o.status === 'cancelled').length,
};
const fulfillmentRate =
totalOrders > 0 ? (ordersByStatus.fulfilled / totalOrders) * 100 : 0;
return {
totalOrders,
totalRevenue,
totalCO2Offset,
fulfillmentRate,
ordersByStatus,
};
}
/**
* Update order status
*/
async updateOrderStatus(recordId: string, status: string): Promise<any> {
return this.request<any>(`/${recordId}`, {
method: 'PATCH',
body: JSON.stringify({ status }),
});
}
/**
* Update order fields
*/
async updateOrder(recordId: string, data: Record<string, any>): Promise<any> {
return this.request<any>(`/${recordId}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
/**
* Create new order record
*/
async createOrder(orderData: Record<string, any>): Promise<any> {
return this.request<any>('', {
method: 'POST',
body: JSON.stringify(orderData),
});
}
/**
* Get orders grouped by time period (for charts)
*/
async getOrdersTimeline(
period: 'day' | 'week' | 'month',
dateFrom?: string,
dateTo?: string
): Promise<Array<{ date: string; count: number; revenue: number }>> {
const filters: OrderFilters = {};
if (dateFrom) filters.dateFrom = dateFrom;
if (dateTo) filters.dateTo = dateTo;
const response = await this.getOrders(filters, { limit: 10000 });
const orders = response.list;
// Group orders by time period
const grouped = new Map<string, { count: number; revenue: number }>();
orders.forEach((order) => {
const date = new Date(order.CreatedAt);
let key: string;
switch (period) {
case 'day':
key = date.toISOString().split('T')[0];
break;
case 'week':
const weekStart = new Date(date);
weekStart.setDate(date.getDate() - date.getDay());
key = weekStart.toISOString().split('T')[0];
break;
case 'month':
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
break;
}
const existing = grouped.get(key) || { count: 0, revenue: 0 };
grouped.set(key, {
count: existing.count + 1,
revenue: existing.revenue + (order.totalAmount || 0),
});
});
return Array.from(grouped.entries())
.map(([date, data]) => ({ date, ...data }))
.sort((a, b) => a.date.localeCompare(b.date));
}
/**
* Get count of records matching filters
*/
async getCount(filters: OrderFilters = {}): Promise<number> {
const whereClause = this.buildWhereClause(filters);
const params = whereClause ? `?where=${whereClause}` : '';
const countUrl = `${this.config.baseUrl}/api/v2/tables/${this.config.ordersTableId}/records/count${params}`;
const response = await this.request<{ count: number }>(countUrl);
return response.count;
}
}
// Export singleton instance
export const nocodbClient = new NocoDBClient();
// Export types for use in other files
export type { OrderFilters, PaginationParams, NocoDBResponse, OrderStats };

View File

@ -1,10 +1,11 @@
import axios from 'axios'; import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
import type { OffsetOrder, OffsetOrderSource, Portfolio } from '../types'; import type { OffsetOrder, Portfolio } from '../types';
import { config } from '../utils/config';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
// Default portfolio for fallback // Default portfolio for fallback
const DEFAULT_PORTFOLIO: Portfolio = { const DEFAULT_PORTFOLIO: Portfolio = {
id: 2, id: 2, // Updated to use ID 2 as in the tutorial
name: "Community Tree Planting", name: "Community Tree Planting",
description: "A curated selection of high-impact carbon removal projects focused on carbon sequestration through tree planting.", description: "A curated selection of high-impact carbon removal projects focused on carbon sequestration through tree planting.",
projects: [ projects: [
@ -27,10 +28,85 @@ const DEFAULT_PORTFOLIO: Portfolio = {
currency: 'USD' currency: 'USD'
}; };
/** // Create API client with error handling, timeout, and retry logic
* Get portfolios from Wren API via server-side proxy const createApiClient = () => {
* This keeps the Wren API token secure on the server if (!config.wrenApiKey) {
*/ console.error('Wren API token is missing! Token:', config.wrenApiKey);
console.error('Environment:', window?.env ? JSON.stringify(window.env) : 'No window.env available');
throw new Error('Wren API token is not configured');
}
logger.log('[wrenClient] Creating API client with key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
const client = axios.create({
// Updated base URL to match the tutorial exactly
baseURL: 'https://www.wren.co/api',
headers: {
'Authorization': `Bearer ${config.wrenApiKey}`,
'Content-Type': 'application/json'
},
timeout: 10000, // 10 second timeout
validateStatus: (status: number) => status >= 200 && status < 500, // Handle 4xx errors gracefully
});
// Add request interceptor for logging
client.interceptors.request.use(
(config: AxiosRequestConfig) => {
if (!config.headers?.Authorization) {
throw new Error('API token is required');
}
logger.log('[wrenClient] Making API request to:', config.url);
return config;
},
(error: Error) => {
console.error('[wrenClient] Request configuration error:', error.message);
return Promise.reject(error);
}
);
// Add response interceptor for error handling
client.interceptors.response.use(
(response: AxiosResponse) => {
logger.log('[wrenClient] Received API response:', response.status);
return response;
},
(error: unknown) => {
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNABORTED') {
logger.warn('[wrenClient] Request timeout, using fallback data');
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
}
if (!error.response) {
logger.warn('[wrenClient] Network error, using fallback data');
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
}
if (error.response.status === 401) {
logger.warn('[wrenClient] Authentication failed, using fallback data');
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
}
console.error('[wrenClient] API error:', error.response?.status, error.response?.data);
}
return Promise.reject(error);
}
);
return client;
};
// Safe error logging function that handles non-serializable objects
const logError = (error: unknown) => {
if (error instanceof Error) {
const errorInfo = {
name: error.name,
message: error.message,
stack: error.stack
};
console.error('[wrenClient] API Error:', JSON.stringify(errorInfo, null, 2));
} else {
console.error('[wrenClient] Unknown error:', String(error));
}
};
export async function getPortfolios(): Promise<Portfolio[]> { export async function getPortfolios(): Promise<Portfolio[]> {
const startTime = Date.now(); const startTime = Date.now();
console.log('🔵 [WREN API] ========================================'); console.log('🔵 [WREN API] ========================================');
@ -38,9 +114,19 @@ export async function getPortfolios(): Promise<Portfolio[]> {
console.log('🔵 [WREN API] Timestamp:', new Date().toISOString()); console.log('🔵 [WREN API] Timestamp:', new Date().toISOString());
try { try {
console.log('🔵 [WREN API] Making request to proxy: /api/wren/portfolios'); if (!config.wrenApiKey) {
console.warn('⚠️ [WREN API] No API token configured, using fallback portfolio');
logger.warn('[wrenClient] No Wren API token configured, using fallback portfolio');
return [DEFAULT_PORTFOLIO];
}
const response = await axios.get('/api/wren/portfolios'); console.log('🔵 [WREN API] API Key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
logger.log('[wrenClient] Getting portfolios with token:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
const api = createApiClient();
console.log('🔵 [WREN API] Making request to: https://www.wren.co/api/portfolios');
const response = await api.get('/portfolios');
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
console.log('✅ [WREN API] GET /portfolios - Success'); console.log('✅ [WREN API] GET /portfolios - Success');
@ -61,7 +147,7 @@ export async function getPortfolios(): Promise<Portfolio[]> {
return response.data.portfolios.map((portfolio: any) => { return response.data.portfolios.map((portfolio: any) => {
let pricePerTon = 18; // Default price based on the Wren Climate Fund average let pricePerTon = 18; // Default price based on the Wren Climate Fund average
// The API returns cost_per_ton in snake_case // The API returns cost_per_ton in snake_case
if (portfolio.cost_per_ton !== undefined && portfolio.cost_per_ton !== null) { if (portfolio.cost_per_ton !== undefined && portfolio.cost_per_ton !== null) {
pricePerTon = typeof portfolio.cost_per_ton === 'number' ? portfolio.cost_per_ton : parseFloat(portfolio.cost_per_ton) || 18; pricePerTon = typeof portfolio.cost_per_ton === 'number' ? portfolio.cost_per_ton : parseFloat(portfolio.cost_per_ton) || 18;
@ -72,30 +158,29 @@ export async function getPortfolios(): Promise<Portfolio[]> {
} }
// Convert from snake_case to camelCase for projects // Convert from snake_case to camelCase for projects
const projects = portfolio.projects?.map((project: any) => { const projects = portfolio.projects?.map(project => {
// Ensure cost_per_ton is properly mapped
const projectPricePerTon = project.cost_per_ton !== undefined && project.cost_per_ton !== null const projectPricePerTon = project.cost_per_ton !== undefined && project.cost_per_ton !== null
? (typeof project.cost_per_ton === 'number' ? project.cost_per_ton : parseFloat(project.cost_per_ton)) ? (typeof project.cost_per_ton === 'number' ? project.cost_per_ton : parseFloat(project.cost_per_ton))
: pricePerTon; : pricePerTon;
// Ensure percentage is properly captured
const projectPercentage = project.percentage !== undefined && project.percentage !== null const projectPercentage = project.percentage !== undefined && project.percentage !== null
? (typeof project.percentage === 'number' ? project.percentage : parseFloat(project.percentage)) ? (typeof project.percentage === 'number' ? project.percentage : parseFloat(project.percentage))
: undefined; : undefined;
// Map certification status - update 2023 to 2025 standard
let certificationStatus = project.certification_status;
if (certificationStatus === 'standard 2023') {
certificationStatus = 'standard 2025';
}
return { return {
id: project.id || `project-${Math.random().toString(36).substring(2, 9)}`, id: project.id || `project-${Math.random().toString(36).substring(2, 9)}`,
name: project.name, name: project.name,
description: project.description || '', description: project.description || '',
shortDescription: project.short_description || project.description || '', shortDescription: project.short_description || project.description || '',
imageUrl: project.image_url, imageUrl: project.image_url, // Map from snake_case API response
pricePerTon: projectPricePerTon, pricePerTon: projectPricePerTon,
percentage: projectPercentage, percentage: projectPercentage, // Include percentage field
certificationStatus: certificationStatus, certificationStatus: project.certification_status, // Map certification status from API
// Remove fields that aren't in the API
// The required type fields are still in the type definition for compatibility
// but we no longer populate them with default values
location: '', location: '',
type: '', type: '',
verificationStandard: '', verificationStandard: '',
@ -104,7 +189,7 @@ export async function getPortfolios(): Promise<Portfolio[]> {
} }
}; };
}) || []; }) || [];
return { return {
id: portfolio.id, id: portfolio.id,
name: portfolio.name, name: portfolio.name,
@ -121,76 +206,7 @@ export async function getPortfolios(): Promise<Portfolio[]> {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
console.error('❌ [WREN API] Status:', error.response?.status || 'No response'); console.error('❌ [WREN API] Status:', error.response?.status || 'No response');
console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2)); console.error('❌ [WREN API] Status Text:', error.response?.statusText || 'N/A');
} else {
console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error));
}
console.log('🔵 [WREN API] ========================================');
logger.warn('[wrenClient] Failed to fetch portfolios from API, using fallback');
return [DEFAULT_PORTFOLIO];
}
}
/**
* Create offset order with Wren API via server-side proxy
* This keeps the Wren API token secure on the server
*/
export async function createOffsetOrder(
portfolioId: number,
tons: number,
dryRun: boolean = false,
source?: OffsetOrderSource,
note?: string
): Promise<OffsetOrder> {
const startTime = Date.now();
console.log('🔵 [WREN API] ========================================');
console.log('🔵 [WREN API] POST /offset-orders - Request initiated');
console.log('🔵 [WREN API] Timestamp:', new Date().toISOString());
console.log('🔵 [WREN API] Parameters:', JSON.stringify({ portfolioId, tons, dryRun, source, note }, null, 2));
try {
console.log('🔵 [WREN API] Making request to proxy: /api/wren/offset-orders');
logger.log(`[wrenClient] Creating offset order via proxy: portfolio=${portfolioId}, tons=${tons}, dryRun=${dryRun}`);
const response = await axios.post('/api/wren/offset-orders', {
tons,
portfolioId,
dryRun,
source,
note
});
const duration = Date.now() - startTime;
console.log('✅ [WREN API] POST /offset-orders - Success');
console.log('✅ [WREN API] Status:', response.status);
console.log('✅ [WREN API] Duration:', duration + 'ms');
console.log('✅ [WREN API] Order ID:', response.data.id);
console.log('✅ [WREN API] Amount Charged:', response.data.amount_paid_by_customer);
console.log('🔵 [WREN API] ========================================');
logger.log(`[wrenClient] Order created successfully: ${response.data.id}`);
return {
id: response.data.id,
amountCharged: response.data.amount_paid_by_customer,
currency: (response.data.currency?.toUpperCase() || 'USD') as 'USD' | 'EUR' | 'GBP' | 'CHF',
tons: response.data.tons,
portfolio: response.data.portfolio,
status: 'completed',
createdAt: new Date().toISOString(),
dryRun,
source,
note
};
} catch (error) {
const duration = Date.now() - startTime;
console.error('❌ [WREN API] POST /offset-orders - Failed');
console.error('❌ [WREN API] Duration:', duration + 'ms');
if (axios.isAxiosError(error)) {
console.error('❌ [WREN API] Status:', error.response?.status || 'No response');
console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2)); console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2));
console.error('❌ [WREN API] Request URL:', error.config?.url); console.error('❌ [WREN API] Request URL:', error.config?.url);
} else { } else {
@ -198,7 +214,171 @@ export async function createOffsetOrder(
} }
console.log('🔵 [WREN API] ========================================'); console.log('🔵 [WREN API] ========================================');
logger.error('[wrenClient] Failed to create offset order'); logError(error);
throw new Error('Failed to create carbon offset order. Please try again or contact support.'); logger.warn('[wrenClient] Failed to fetch portfolios from API, using fallback');
return [DEFAULT_PORTFOLIO];
}
}
export async function createOffsetOrder(
portfolioId: number,
tons: number,
dryRun: boolean = false
): Promise<OffsetOrder> {
const startTime = Date.now();
console.log('🔵 [WREN API] ========================================');
console.log('🔵 [WREN API] POST /offset-orders - Request initiated');
console.log('🔵 [WREN API] Timestamp:', new Date().toISOString());
console.log('🔵 [WREN API] Parameters:', JSON.stringify({ portfolioId, tons, dryRun }, null, 2));
try {
if (!config.wrenApiKey) {
console.error('❌ [WREN API] Cannot create order - missing API token');
console.error('[wrenClient] Cannot create order - missing API token');
throw new Error('Carbon offset service is currently unavailable. Please contact support.');
}
console.log('🔵 [WREN API] API Key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
logger.log(`[wrenClient] Creating offset order: portfolio=${portfolioId}, tons=${tons}, dryRun=${dryRun}`);
const api = createApiClient();
console.log('🔵 [WREN API] Making request to: https://www.wren.co/api/offset-orders');
console.log('🔵 [WREN API] Request payload:', JSON.stringify({ portfolioId, tons, dryRun }, null, 2));
// Removed the /api prefix to match the working example
const response = await api.post('/offset-orders', {
// Using exactly the format shown in the API tutorial
portfolioId, // Use the provided portfolio ID instead of hardcoding
tons,
dryRun // Use the provided dryRun parameter
});
const duration = Date.now() - startTime;
console.log('✅ [WREN API] POST /offset-orders - Success');
console.log('✅ [WREN API] Status:', response.status);
console.log('✅ [WREN API] Duration:', duration + 'ms');
// Add detailed response logging
logger.log('[wrenClient] Offset order response:',
response.status,
response.data ? 'has data' : 'no data');
if (response.status === 400) {
console.error('❌ [WREN API] Bad request - Status 400');
console.error('❌ [WREN API] Bad request details:', response.data);
console.error('[wrenClient] Bad request details:', response.data);
throw new Error(`Failed to create offset order: ${JSON.stringify(response.data)}`);
}
const order = response.data;
if (!order) {
console.error('❌ [WREN API] Empty response received');
throw new Error('Empty response received from offset order API');
}
console.log('✅ [WREN API] Order ID:', order.id || 'N/A');
console.log('✅ [WREN API] Amount Charged:', order.amountCharged ? `$${order.amountCharged}` : 'N/A');
console.log('✅ [WREN API] Tons:', order.tons || 'N/A');
console.log('✅ [WREN API] Status:', order.status || 'N/A');
console.log('✅ [WREN API] Dry Run:', order.dryRun !== undefined ? order.dryRun : 'N/A');
console.log('🔵 [WREN API] ========================================')
// Log to help diagnose issues
logger.log('[wrenClient] Order data keys:', Object.keys(order).join(', '));
if (order.portfolio) {
logger.log('[wrenClient] Portfolio data keys:', Object.keys(order.portfolio).join(', '));
}
// Get price from API response which uses cost_per_ton
let pricePerTon = 18;
if (order.portfolio?.cost_per_ton !== undefined) {
pricePerTon = typeof order.portfolio.cost_per_ton === 'number' ? order.portfolio.cost_per_ton : parseFloat(order.portfolio.cost_per_ton) || 18;
} else if (order.portfolio?.costPerTon !== undefined) {
pricePerTon = typeof order.portfolio.costPerTon === 'number' ? order.portfolio.costPerTon : parseFloat(order.portfolio.costPerTon) || 18;
} else if (order.portfolio?.pricePerTon !== undefined) {
pricePerTon = typeof order.portfolio.pricePerTon === 'number' ? order.portfolio.pricePerTon : parseFloat(order.portfolio.pricePerTon) || 18;
}
// Create a safe method to extract properties with fallbacks
const getSafeProp = (obj: any, prop: string, fallback: any) => {
if (!obj) return fallback;
return obj[prop] !== undefined ? obj[prop] : fallback;
};
// Use safe accessor to avoid undefined errors
const portfolio = order.portfolio || {};
// Adjusted to use camelCase as per API docs response format
return {
id: getSafeProp(order, 'id', ''),
amountCharged: getSafeProp(order, 'amountCharged', 0),
currency: getSafeProp(order, 'currency', 'USD'),
tons: getSafeProp(order, 'tons', 0),
portfolio: {
id: getSafeProp(portfolio, 'id', 2),
name: getSafeProp(portfolio, 'name', 'Community Tree Planting'),
description: getSafeProp(portfolio, 'description', ''),
projects: getSafeProp(portfolio, 'projects', []),
pricePerTon,
currency: getSafeProp(order, 'currency', 'USD')
},
status: getSafeProp(order, 'status', ''),
createdAt: getSafeProp(order, 'createdAt', new Date().toISOString()),
dryRun: getSafeProp(order, 'dryRun', true)
};
} catch (error: unknown) {
const duration = Date.now() - startTime;
console.error('❌ [WREN API] POST /offset-orders - Failed');
console.error('❌ [WREN API] Duration:', duration + 'ms');
logError(error);
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
console.error('❌ [WREN API] Status:', axiosError.response?.status || 'No response');
console.error('❌ [WREN API] Status Text:', axiosError.response?.statusText || 'N/A');
console.error('❌ [WREN API] Error Data:', JSON.stringify(axiosError.response?.data, null, 2));
console.error('❌ [WREN API] Request URL:', axiosError.config?.url);
console.error('❌ [WREN API] Request Method:', axiosError.config?.method?.toUpperCase());
console.error('❌ [WREN API] Request Data:', JSON.stringify(axiosError.config?.data, null, 2));
console.log('🔵 [WREN API] ========================================');
console.error('[wrenClient] Axios error details:', {
status: axiosError.response?.status,
statusText: axiosError.response?.statusText,
data: axiosError.response?.data,
config: {
url: axiosError.config?.url,
method: axiosError.config?.method,
headers: axiosError.config?.headers ? 'Headers present' : 'No headers',
baseURL: axiosError.config?.baseURL,
data: axiosError.config?.data
}
});
if (axiosError.response?.status === 400) {
// Provide more specific error for 400 Bad Request
const responseData = axiosError.response.data as any;
const errorMessage = responseData?.message || responseData?.error || 'Invalid request format';
throw new Error(`Bad request: ${errorMessage}`);
}
if (axiosError.code === 'ECONNABORTED') {
throw new Error('Request timed out. Please try again.');
}
if (!axiosError.response) {
throw new Error('Network error. Please check your connection and try again.');
}
if (axiosError.response.status === 401) {
throw new Error('Carbon offset service authentication failed. Please check your API token.');
}
} else {
console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error));
console.log('🔵 [WREN API] ========================================');
}
throw new Error('Failed to create offset order. Please try again.');
} }
} }

View File

@ -1,5 +1,5 @@
// React import removed - not needed with JSX transform import React from 'react';
import { Heart, Leaf, Scale, FileCheck, Handshake, Rocket } from 'lucide-react'; import { Anchor, Heart, Leaf, Scale, CreditCard, FileCheck, Handshake, Rocket } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
interface Props { interface Props {

Some files were not shown because too many files have changed in this diff Show More