Compare commits
No commits in common. "main" and "vite-version-reference" have entirely different histories.
main
...
vite-versi
@ -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": []
|
||||||
|
|||||||
11
.env.example
11
.env.example
@ -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
3
.gitignore
vendored
@ -29,6 +29,3 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
/.next/
|
|
||||||
/.playwright-mcp/
|
|
||||||
/nul
|
|
||||||
|
|||||||
@ -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`
|
|
||||||
30
Dockerfile
30
Dockerfile
@ -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"]
|
|
||||||
|
|||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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 };
|
|
||||||
@ -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 />;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 />;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 />;
|
|
||||||
}
|
|
||||||
279
app/globals.css
279
app/globals.css
@ -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);
|
|
||||||
}
|
|
||||||
@ -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 />;
|
|
||||||
}
|
|
||||||
106
app/layout.tsx
106
app/layout.tsx
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
337
app/page.tsx
337
app/page.tsx
@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 CO₂e 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 CO₂e 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -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`
|
|
||||||
@ -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)
|
|
||||||
@ -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
@ -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" />
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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
6
next-env.d.ts
vendored
@ -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.
|
|
||||||
@ -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;
|
|
||||||
@ -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
1788
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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 />);
|
|
||||||
@ -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 |
137
public/sw.js
137
public/sw.js
@ -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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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', {
|
||||||
|
|||||||
@ -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();
|
|
||||||
@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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
469
src/App.tsx
Normal 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;
|
||||||
@ -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> {
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 };
|
|
||||||
@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
Loading…
x
Reference in New Issue
Block a user