stripe not working yet, broken on sync

This commit is contained in:
Jeff Emmett 2025-07-27 08:57:34 -04:00
parent 7b4994fb3e
commit 6cb69c9bc4
20 changed files with 1636 additions and 17 deletions

28
CLAUDE.md Normal file
View File

@ -0,0 +1,28 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build Commands
- `npm run dev` - Run development servers (client + worker)
- `npm run build` - Build for production
- `npm run deploy` - Build and deploy to Vercel/Cloudflare
- `npm run types` - TypeScript type checking
## Code Style Guidelines
- **TypeScript**: Use strict mode, explicit return types, and proper interfaces for props
- **Formatting**: No semicolons, trailing commas for all elements
- **Naming**: PascalCase for components/types, camelCase for utilities/functions
- **Imports**: Group related imports, React imports first, use absolute paths with aliases
- **Error Handling**: Catch and log errors with console.error, return success/failure values
- **Components**: Separate UI from business logic, use functional components with hooks
- **State**: Use React Context for global state, follow immutable update patterns
- **Documentation**: Include JSDoc comments for functions and modules
## Project Structure
- `/src/components/` - UI components organized by feature
- `/src/context/` - React Context providers
- `/src/lib/` - Business logic and utilities
- `/src/routes/` - Page definitions
- `/src/css/` - Styles organized by feature
- `/src/ui/` - Reusable UI components
- `/worker/` - Cloudflare Workers backend code

198
STRIPE_SETUP.md Normal file
View File

@ -0,0 +1,198 @@
# Stripe Integration Setup for Canvas Website
This document outlines the setup process for integrating Stripe payments into the canvas website, specifically for subscription plans.
## Overview
The Stripe integration allows users to create subscription payment forms directly on the canvas. Users can select from predefined subscription plans and complete their subscription using Stripe's secure payment processing.
## Features
- **Subscription Plans**: Three predefined plans (Basic, Pro, Enterprise)
- **Interactive UI**: Plan selection with feature comparison
- **Secure Payments**: Stripe Elements integration
- **Webhook Support**: Real-time subscription event handling
- **Customer Management**: Automatic customer creation and management
## Setup Instructions
### 1. Install Stripe CLI
First, install the Stripe CLI on your system:
```bash
# For Ubuntu/Debian
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
sudo apt update
sudo apt install stripe
# For macOS
brew install stripe/stripe-cli/stripe
# For Windows
# Download from https://github.com/stripe/stripe-cli/releases
```
### 2. Login to Stripe
```bash
stripe login
```
This will open your browser to authenticate with your Stripe account.
### 3. Get Your API Keys
1. Go to your [Stripe Dashboard](https://dashboard.stripe.com/)
2. Navigate to **Developers** > **API keys**
3. Copy your **Publishable key** and **Secret key**
### 4. Set Environment Variables
Create or update your `.dev.vars` file with the following variables:
```env
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
```
### 5. Set Up Webhooks
Run the following command to start listening for webhook events:
```bash
stripe listen --forward-to localhost:8787/api/stripe/webhook
```
This will output a webhook secret. Copy this secret and add it to your `.dev.vars` file as `STRIPE_WEBHOOK_SECRET`.
### 6. Create Subscription Products and Prices
You'll need to create the subscription products and prices in your Stripe dashboard that correspond to the plans defined in the code:
#### Basic Plan
- Product Name: "Basic Plan"
- Price: $9.99/month
- Price ID: Use this ID in the code (currently set to 'basic')
#### Pro Plan
- Product Name: "Pro Plan"
- Price: $19.99/month
- Price ID: Use this ID in the code (currently set to 'pro')
#### Enterprise Plan
- Product Name: "Enterprise Plan"
- Price: $49.99/month
- Price ID: Use this ID in the code (currently set to 'enterprise')
### 7. Update Price IDs
After creating the products and prices in Stripe, update the `SUBSCRIPTION_PLANS` array in `src/shapes/stripe/StripePaymentShapeUtil.tsx` with the actual Stripe price IDs:
```typescript
const SUBSCRIPTION_PLANS = [
{
id: 'price_actual_stripe_price_id_here', // Replace with actual Stripe price ID
name: 'Basic Plan',
price: 999,
interval: 'month',
description: 'Perfect for individuals',
features: ['Basic features', 'Email support', '1GB storage']
},
// ... update other plans similarly
];
```
## API Endpoints
The integration provides the following API endpoints:
- `POST /api/stripe/create-subscription` - Creates a new subscription
- `POST /api/stripe/create-payment-intent` - Creates a payment intent (for one-time payments)
- `POST /api/stripe/webhook` - Handles Stripe webhook events
## Webhook Events
The webhook handler processes the following events:
- `customer.subscription.created` - New subscription created
- `customer.subscription.updated` - Subscription updated
- `customer.subscription.deleted` - Subscription deleted
- `invoice.payment_succeeded` - Invoice payment successful
- `invoice.payment_failed` - Invoice payment failed
- `payment_intent.succeeded` - Payment intent successful
- `payment_intent.payment_failed` - Payment intent failed
## Usage
### Adding a Subscription Form to Canvas
1. Use the Stripe tool from the toolbar (shortcut: `Alt+Shift+P`)
2. Select a subscription plan from the available options
3. Enter customer email (optional)
4. Choose theme preference
5. Click "Subscribe" to initialize the payment form
6. Complete payment details using Stripe Elements
### Keyboard Shortcuts
- `Alt+Shift+P` - Add Stripe subscription form
- `Alt+Shift+S` - Quick action to add subscription form
### Context Menu
Right-click on the canvas to access the "Add Stripe Subscription" option.
## Testing
### Test Cards
Use these test card numbers for testing:
- **Success**: `4242 4242 4242 4242`
- **Decline**: `4000 0000 0000 0002`
- **Requires Authentication**: `4000 0025 0000 3155`
### Test Mode
The integration runs in test mode by default. All transactions are test transactions and won't result in actual charges.
## Security Considerations
- Never expose your Stripe secret key in client-side code
- Always verify webhook signatures
- Use HTTPS in production
- Implement proper error handling
- Validate all input data
## Troubleshooting
### Common Issues
1. **Webhook not receiving events**: Ensure the webhook endpoint is accessible and the secret is correct
2. **Payment form not loading**: Check that the publishable key is correct
3. **Subscription creation fails**: Verify the price IDs exist in your Stripe account
4. **CORS errors**: Ensure the worker is properly configured for CORS
### Debug Mode
Enable debug logging by setting the appropriate environment variables or checking the browser console and worker logs.
## Production Deployment
When deploying to production:
1. Switch to live API keys
2. Update webhook endpoints to production URLs
3. Configure proper CORS settings
4. Set up monitoring and alerting
5. Test the complete subscription flow
## Support
For issues related to:
- **Stripe API**: Check [Stripe Documentation](https://stripe.com/docs)
- **Integration**: Review this setup guide and check the code comments
- **Canvas Website**: Refer to the main project documentation

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html>
<html lang="en">
<head>
<title>Jeff Emmett</title>
@ -33,6 +33,23 @@
<!-- Analytics -->
<script data-goatcounter="https://jeff.goatcounter.com/count" async src="//gc.zgo.at/count.js"></script>
<meta name="mobile-web-app-capable" content="yes">
<script>
// Check if we're on a subscription success page
if (window.location.pathname === '/subscription-success') {
document.body.innerHTML = `
<div style="display: flex; justify-content: center; align-items: center; height: 100vh; font-family: system-ui, sans-serif;">
<div style="text-align: center; padding: 40px; border-radius: 8px; background: #f8f9fa; border: 1px solid #e9ecef;">
<h1 style="color: #28a745; margin-bottom: 16px;">🎉 Subscription Successful!</h1>
<p style="margin-bottom: 24px; color: #6c757d;">Your subscription has been activated successfully.</p>
<button onclick="window.location.href='/'" style="padding: 12px 24px; background: #0066cc; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px;">
Return to Canvas
</button>
</div>
</div>
`;
}
</script>
</head>
<body>

530
package-lock.json generated
View File

@ -12,6 +12,8 @@
"@anthropic-ai/sdk": "^0.33.1",
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"@tldraw/assets": "^3.6.0",
"@tldraw/sync": "^3.6.0",
"@tldraw/sync-core": "^3.6.0",
@ -25,6 +27,7 @@
"cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7",
"gray-matter": "^4.0.3",
"holosphere": "^1.1.17",
"html2canvas": "^1.4.1",
"itty-router": "^5.0.17",
"jotai": "^2.6.0",
@ -38,6 +41,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"stripe": "^18.2.1",
"tldraw": "^3.6.0",
"vercel": "^39.1.1"
},
@ -1847,6 +1851,48 @@
"node": ">=8.0.0"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.4.0.tgz",
"integrity": "sha512-umbembjIWOrPSOzEGG5vxFLkeM8kzIhLkgigtsOrfLKnuzxWxejAcUX+q/SoZCdemlODOcr5WiYa7+dIEzBXZQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"asn1js": "^3.0.6",
"pvtsutils": "^1.3.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/json-schema": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz",
"integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@peculiar/webcrypto": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.5.0.tgz",
"integrity": "sha512-BRs5XUAwiyCDQMsVA9IDvDa7UBR9gAvPHgugOeGng3YN6vJ9JYonyDc0lNczErgtCWtucjR5N7VtaonboD/ezg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@peculiar/asn1-schema": "^2.3.8",
"@peculiar/json-schema": "^1.1.12",
"pvtsutils": "^1.3.5",
"tslib": "^2.6.2",
"webcrypto-core": "^1.8.0"
},
"engines": {
"node": ">=10.12.0"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz",
@ -3012,6 +3058,29 @@
"integrity": "sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ==",
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.7.0.tgz",
"integrity": "sha512-PYls/2S9l0FF+2n0wHaEJsEU8x7CmBagiH7zYOsxbBlLIHEsqUIQ4MlIAbV9Zg6xwT8jlYdlRIyBTHmO3yM7kQ==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=1.44.1 <8.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.3.1.tgz",
"integrity": "sha512-pTzb864TQWDRQBPLgSPFRoyjSDUqpCkbEgTzpsjiTjGz1Z5SxZNXJek28w1s6Dyry4CyW4/Izj5jHE/J9hCJYQ==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@tldraw/assets": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/@tldraw/assets/-/assets-3.6.1.tgz",
@ -4286,6 +4355,21 @@
"printable-characters": "^1.0.42"
}
},
"node_modules/asn1js": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz",
"integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/async-listen": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/async-listen/-/async-listen-1.2.0.tgz",
@ -4470,6 +4554,35 @@
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001690",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz",
@ -5644,6 +5757,20 @@
"license": "(MPL-2.0 OR Apache-2.0)",
"optional": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/edge-runtime": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/edge-runtime/-/edge-runtime-2.5.9.tgz",
@ -5723,12 +5850,42 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.4.1.tgz",
"integrity": "sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==",
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.14.47",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.47.tgz",
@ -6317,6 +6474,22 @@
"license": "MIT",
"optional": true
},
"node_modules/fast-uri": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastq": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
@ -6458,6 +6631,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gauge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
@ -6514,6 +6696,30 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@ -6523,6 +6729,19 @@
"node": ">=6"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-source": {
"version": "2.0.12",
"resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz",
@ -6605,6 +6824,18 @@
"node": ">=4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -6626,6 +6857,53 @@
"node": ">=6.0"
}
},
"node_modules/gun": {
"version": "0.2020.1241",
"resolved": "https://registry.npmjs.org/gun/-/gun-0.2020.1241.tgz",
"integrity": "sha512-rmGqLuJj4fAuZ/0lddCvXHbENPkEnBOBYpq+kXHrwQ5RdNtQ5p0Io99lD1qUXMFmtwNacQ/iqo3VTmjmMyAYZg==",
"license": "(Zlib OR MIT OR Apache-2.0)",
"dependencies": {
"ws": "^7.2.1"
},
"engines": {
"node": ">=0.8.4"
},
"optionalDependencies": {
"@peculiar/webcrypto": "^1.1.1"
}
},
"node_modules/gun/node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/h3-js": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.2.1.tgz",
"integrity": "sha512-HYiUrq5qTRFqMuQu3jEHqxXLk1zsSJiby9Lja/k42wHjabZG7tN9rOuzT/PEFf+Wa7rsnHLMHRWIu0mgcJ0ewQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=4",
"npm": ">=3",
"yarn": ">=1.3.0"
}
},
"node_modules/hamt_plus": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/hamt_plus/-/hamt_plus-1.0.2.tgz",
@ -6642,12 +6920,36 @@
"node": ">=8"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC"
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hast-util-from-html": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz",
@ -6936,6 +7238,49 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/holosphere": {
"version": "1.1.17",
"resolved": "https://registry.npmjs.org/holosphere/-/holosphere-1.1.17.tgz",
"integrity": "sha512-tQsP9lFoOnU1KDqU2s+TZTj3P5GCzN8DXtl2VXSIg1DY+8SoflqdI7nFAfW8JrgqIBvRTKKq38Zw22gdrhGZnA==",
"license": "GPL-3.0-or-later",
"dependencies": {
"ajv": "^8.12.0",
"gun": "^0.2020.1240",
"h3-js": "^4.1.0",
"openai": "^4.85.1"
},
"peerDependencies": {
"gun": "^0.2020.1240",
"h3-js": "^4.1.0"
},
"peerDependenciesMeta": {
"openai": {
"optional": true
}
}
},
"node_modules/holosphere/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/holosphere/node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/hotkeys-js": {
"version": "3.13.9",
"resolved": "https://registry.npmjs.org/hotkeys-js/-/hotkeys-js-3.13.9.tgz",
@ -7635,6 +7980,15 @@
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
@ -8906,6 +9260,18 @@
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ohash": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz",
@ -8938,9 +9304,9 @@
}
},
"node_modules/openai": {
"version": "4.79.3",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.79.3.tgz",
"integrity": "sha512-0yAnr6oxXAyVrYwLC1jA0KboyU7DjEmrfTXQX+jSpE+P4i72AI/Lxx5pvR3r9i5X7G33835lL+ZrnQ+MDvyuUg==",
"version": "4.104.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
@ -9185,6 +9551,17 @@
"integrity": "sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==",
"license": "MIT"
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/property-information": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.0.0.tgz",
@ -9226,6 +9603,41 @@
"node": ">=6"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz",
"integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
@ -9345,6 +9757,12 @@
"react": "^18.3.1"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-markdown": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@ -10241,6 +10659,78 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz",
@ -10460,6 +10950,26 @@
"node": ">=6"
}
},
"node_modules/stripe": {
"version": "18.2.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.2.1.tgz",
"integrity": "sha512-GwB1B7WSwEBzW4dilgyJruUYhbGMscrwuyHsPUmSRKrGHZ5poSh2oU9XKdii5BFVJzXHn35geRvGJ6R8bYcp8w==",
"license": "MIT",
"dependencies": {
"qs": "^6.11.0"
},
"engines": {
"node": ">=12.*"
},
"peerDependencies": {
"@types/node": ">=12.x.x"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/style-to-js": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz",
@ -11328,6 +11838,20 @@
"license": "Apache-2.0",
"optional": true
},
"node_modules/webcrypto-core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.8.1.tgz",
"integrity": "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A==",
"license": "MIT",
"optional": true,
"dependencies": {
"@peculiar/asn1-schema": "^2.3.13",
"@peculiar/json-schema": "^1.1.12",
"asn1js": "^3.0.5",
"pvtsutils": "^1.3.5",
"tslib": "^2.7.0"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View File

@ -19,6 +19,8 @@
"@anthropic-ai/sdk": "^0.33.1",
"@daily-co/daily-js": "^0.60.0",
"@daily-co/daily-react": "^0.20.0",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"@tldraw/assets": "^3.6.0",
"@tldraw/sync": "^3.6.0",
"@tldraw/sync-core": "^3.6.0",
@ -32,6 +34,7 @@
"cherry-markdown": "^0.8.57",
"cloudflare-workers-unfurl": "^0.0.7",
"gray-matter": "^4.0.3",
"holosphere": "^1.1.17",
"html2canvas": "^1.4.1",
"itty-router": "^5.0.17",
"jotai": "^2.6.0",
@ -45,6 +48,7 @@
"react-markdown": "^10.1.0",
"react-router-dom": "^7.0.2",
"recoil": "^0.7.7",
"stripe": "^18.2.1",
"tldraw": "^3.6.0",
"vercel": "^39.1.1"
},

View File

@ -37,6 +37,8 @@ import {
initLockIndicators,
watchForLockedShapes,
} from "@/ui/cameraUtils"
import { StripePaymentShapeUtil } from "@/shapes/stripe/StripePaymentShapeUtil"
import { StripePaymentTool } from "@/tools/StripePaymentTool"
// Default to production URL if env var isn't available
export const WORKER_URL = "https://jeffemmett-canvas.jeffemmett.workers.dev"
@ -49,6 +51,7 @@ const customShapeUtils = [
MycrozineTemplateShape,
MarkdownShape,
PromptShape,
StripePaymentShapeUtil,
]
const customTools = [
ChatBoxTool,
@ -58,6 +61,7 @@ const customTools = [
MycrozineTemplateTool,
MarkdownTool,
PromptShapeTool,
StripePaymentTool,
]
export function Board() {
@ -97,6 +101,13 @@ export function Board() {
watchForLockedShapes(editor)
}, [editor])
// Cleanup global editor reference on unmount
useEffect(() => {
return () => {
delete (window as any).__TLDRAW_EDITOR__
}
}, [])
return (
<div style={{ position: "fixed", inset: 0 }}>
<Tldraw
@ -136,6 +147,8 @@ export function Board() {
}}
onMount={(editor) => {
setEditor(editor)
// Expose editor globally for Stripe component access
;(window as any).__TLDRAW_EDITOR__ = editor
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.setCurrentTool("hand")
setInitialCameraFromUrl(editor)

View File

@ -112,15 +112,15 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
}}
preview='live'
visibleDragbar={true}
height={shape.props.h - 2}
style={{
height: 'auto',
minHeight: '100%',
width: '100%',
border: 'none',
backgroundColor: 'transparent',
}}
previewOptions={{
style: {
padding: '12px',
padding: '12px',
backgroundColor: 'transparent',
}
}}
@ -128,8 +128,6 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
style: {
padding: '12px',
lineHeight: '1.5',
height: 'auto',
minHeight: '100%',
resize: 'none',
backgroundColor: 'transparent',
}
@ -137,6 +135,8 @@ export class MarkdownShape extends BaseBoxShapeUtil<IMarkdownShape> {
onPointerDown={(e) => {
e.stopPropagation()
}}
hideToolbar={false}
enableScroll={true}
/>
</div>
</div>

View File

@ -0,0 +1,33 @@
import React, { useState } from 'react';
import { StripePaymentPopup } from './StripePaymentShapeUtil';
export function ModalManager() {
const [isOpen, setIsOpen] = useState(false);
if (isOpen) {
return <StripePaymentPopup onClose={() => setIsOpen(false)} />;
}
return (
<button
onClick={() => setIsOpen(true)}
style={{
position: 'fixed',
top: '20px',
right: '20px',
zIndex: 1000,
padding: '12px 24px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '16px',
fontWeight: '600',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
}}
>
💳 Open Payment
</button>
);
}

View File

@ -0,0 +1,9 @@
import { TLBaseShape } from 'tldraw';
export type StripePaymentShape = TLBaseShape<
'stripe-payment',
{
w: number;
h: number;
}
>;

View File

@ -0,0 +1,479 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
BaseBoxShapeUtil,
HTMLContainer,
RecordProps,
T,
TLBaseShape,
} from 'tldraw';
import { Elements, PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
// Declare global variable from vite config
declare const __STRIPE_PUBLISHABLE_KEY__: string;
// Define the shape type inline to avoid import conflicts
export type StripePaymentShape = TLBaseShape<
'stripe-payment',
{
w: number;
h: number;
}
>;
// Define subscription plans
const SUBSCRIPTION_PLANS = [
{
id: 'price_1RdDMgKFe1dC1xn7p319KRDU', // Basic Support Stream
name: 'Basic Support Stream',
price: 500, // $5.00 CAD
interval: 'month',
description: 'I like what you\'re doing',
features: ['Yay support']
},
{
id: 'price_1RdDMwKFe1dC1xn7kDSgE95J', // Mid-range support stream
name: 'Mid-range support stream',
price: 2500, // $25.00 CAD
interval: 'month',
description: 'Wait this stuff could actually be helpful',
features: ['Even Yayer']
},
{
id: 'price_1RdDNAKFe1dC1xn7x2n0FUI5', // Comrades & Collaborators
name: 'Comrades & Collaborators',
price: 5000, // $50.00 CAD
interval: 'month',
description: 'We are the ones we\'ve been waiting for',
features: ['The yayest of them all']
}
];
// Stripe Payment Form Component
function StripePaymentForm({ clientSecret }: { clientSecret: string }) {
const stripe = useStripe();
const elements = useElements();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState(false);
const mountedRef = useRef(true);
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!stripe || !elements || !mountedRef.current) return;
setProcessing(true);
setError(null);
try {
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: window.location.href },
});
if (error && mountedRef.current) {
setError(error.message || 'Payment failed');
}
} catch (err) {
if (mountedRef.current) {
setError('An unexpected error occurred');
}
} finally {
if (mountedRef.current) {
setProcessing(false);
}
}
};
if (typeof __STRIPE_PUBLISHABLE_KEY__ === 'undefined' || !__STRIPE_PUBLISHABLE_KEY__) {
return <div style={{ color: '#dc3545', textAlign: 'center', padding: '20px' }}>
<div>Stripe configuration error. Please check your setup.</div>
</div>;
}
const stripePromise = loadStripe(__STRIPE_PUBLISHABLE_KEY__);
return (
<Elements
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: 'flat',
variables: {
colorPrimary: '#0066cc',
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
fontSizeBase: '16px',
spacingUnit: '8px',
borderRadius: '8px',
},
},
}}
>
<form onSubmit={handleSubmit} style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div style={{ flexShrink: 0 }}>
<h2 style={{ margin: '0 0 12px 0', fontSize: '24px', fontWeight: '700', color: '#1a1a1a', textAlign: 'center' }}>
Complete Your Subscription
</h2>
<p style={{ margin: 0, color: '#666', fontSize: '14px', textAlign: 'center', lineHeight: '1.4' }}>
Enter your payment details to start your subscription
</p>
</div>
<div style={{ flex: 1, marginBottom: '16px', minHeight: '180px' }}>
<PaymentElement />
</div>
{error && (
<div style={{
color: '#dc3545', marginBottom: '16px', fontSize: '14px',
padding: '12px', backgroundColor: '#f8d7da', border: '1px solid #f5c6cb',
borderRadius: '8px', textAlign: 'center'
}}>
{error}
</div>
)}
<button
type="submit"
disabled={!stripe || processing}
style={{
width: '100%', padding: '14px',
backgroundColor: processing ? '#ccc' : '#0066cc',
color: 'white', border: 'none', borderRadius: '8px',
fontSize: '16px', fontWeight: '600',
cursor: processing ? 'not-allowed' : 'pointer',
transition: 'background-color 0.2s ease', minHeight: '44px',
}}
>
{processing ? 'Processing...' : 'Start Subscription'}
</button>
</form>
</Elements>
);
}
// Stripe Payment Popup Component
export function StripePaymentPopup({ onClose }: { onClose: () => void }) {
const [selectedPlanId, setSelectedPlanId] = useState('price_1RdDMgKFe1dC1xn7p319KRDU');
const [customerEmail, setCustomerEmail] = useState('');
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const mountedRef = useRef(true);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
mountedRef.current = false;
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
}, []);
const initializeSubscription = useCallback(async () => {
// Only proceed if we have a valid email and aren't already loading
if (isLoading || clientSecret || !customerEmail.trim() || !mountedRef.current) return;
try {
setIsLoading(true);
setError(null);
// Create new abort controller for this request
abortControllerRef.current = new AbortController();
const selectedPlan = SUBSCRIPTION_PLANS.find(plan => plan.id === selectedPlanId) || SUBSCRIPTION_PLANS[0];
const response = await fetch('/api/stripe/create-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
priceId: selectedPlan.id,
customerEmail: customerEmail.trim(),
metadata: { planId: selectedPlan.id },
}),
signal: abortControllerRef.current.signal,
});
if (!mountedRef.current) return;
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json() as { client_secret: string; payment_intent_id?: string; customer_id?: string; price_id?: string };
if (!data.client_secret) {
throw new Error('No client secret received from server');
}
if (mountedRef.current) {
setClientSecret(data.client_secret);
}
} catch (err) {
if (mountedRef.current) {
console.error('Stripe: Subscription initialization error:', err);
setError('Failed to initialize subscription. Please try again.');
}
} finally {
if (mountedRef.current) {
setIsLoading(false);
}
}
}, [selectedPlanId, customerEmail, isLoading, clientSecret]);
return (
<div style={{
position: 'fixed',
top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
}}>
<div style={{
width: '600px',
height: '700px',
backgroundColor: '#ffffff',
borderRadius: '12px',
padding: '20px',
display: 'flex',
flexDirection: 'column',
position: 'relative',
overflow: 'hidden',
}}>
<button
onClick={onClose}
style={{
position: 'absolute',
top: '16px', right: '16px',
width: '32px', height: '32px',
border: 'none',
backgroundColor: '#f8f9fa',
borderRadius: '50%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px',
zIndex: 1,
}}
>
×
</button>
{!clientSecret ? (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', gap: '16px', overflow: 'hidden' }}>
<h2 style={{ margin: '0 0 16px 0', fontSize: '24px', fontWeight: '700', color: '#1a1a1a', textAlign: 'center' }}>
Choose Your Plan
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '16px', flex: 1, overflow: 'auto' }}>
{SUBSCRIPTION_PLANS.map((plan) => (
<div
key={plan.id}
style={{
padding: '16px',
border: `2px solid ${selectedPlanId === plan.id ? '#0066cc' : '#e0e0e0'}`,
borderRadius: '8px',
cursor: 'pointer',
backgroundColor: selectedPlanId === plan.id ? '#f0f8ff' : 'transparent',
transition: 'all 0.2s ease',
minHeight: '100px',
}}
onClick={() => setSelectedPlanId(plan.id)}
>
<h3 style={{ margin: '0 0 6px 0', fontSize: '18px', fontWeight: '600', color: '#1a1a1a' }}>
{plan.name}
</h3>
<div style={{ fontSize: '24px', fontWeight: '700', color: '#0066cc', marginBottom: '6px' }}>
${(plan.price / 100).toFixed(2)}/{plan.interval}
</div>
<p style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#666', lineHeight: '1.4' }}>
{plan.description}
</p>
<ul style={{ margin: 0, paddingLeft: '16px', fontSize: '12px', lineHeight: '1.4' }}>
{plan.features.map((feature, index) => (
<li key={index} style={{ marginBottom: '2px' }}>{feature}</li>
))}
</ul>
</div>
))}
</div>
<div style={{ marginTop: 'auto', flexShrink: 0, display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: '600', fontSize: '14px', color: '#1a1a1a' }}>
Email Address
</label>
<input
type="email"
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
placeholder="Enter your email"
style={{
width: '100%',
padding: '10px 12px',
border: '2px solid #ddd',
borderRadius: '6px',
fontSize: '14px',
boxSizing: 'border-box',
}}
/>
</div>
<button
onClick={initializeSubscription}
disabled={isLoading || !customerEmail.trim()}
style={{
width: '100%',
padding: '14px',
backgroundColor: (isLoading || !customerEmail.trim()) ? '#ccc' : '#0066cc',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: '16px',
fontWeight: '600',
cursor: (isLoading || !customerEmail.trim()) ? 'not-allowed' : 'pointer',
transition: 'background-color 0.2s ease',
minHeight: '44px',
}}
>
{isLoading ? 'Initializing...' : !customerEmail.trim() ? 'Enter email to continue' : `Subscribe to ${SUBSCRIPTION_PLANS.find(plan => plan.id === selectedPlanId)?.name} - $${(SUBSCRIPTION_PLANS.find(plan => plan.id === selectedPlanId)?.price || 0) / 100}/month`}
</button>
</div>
</div>
) : (
<StripePaymentForm clientSecret={clientSecret} />
)}
{error && (
<div style={{
position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
backgroundColor: '#ffffff', padding: '32px', borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)', textAlign: 'center', zIndex: 2,
}}>
<div style={{ fontSize: '40px', color: '#dc3545', marginBottom: '12px' }}></div>
<div style={{ color: '#dc3545', fontSize: '16px', fontWeight: '600', marginBottom: '20px' }}>
{error}
</div>
<button
onClick={() => { setError(null); setClientSecret(null); initializeSubscription(); }}
style={{
padding: '10px 20px', backgroundColor: '#0066cc', color: 'white',
border: 'none', borderRadius: '6px', cursor: 'pointer',
fontSize: '14px', fontWeight: '600', minHeight: '40px', minWidth: '100px'
}}
>
Retry
</button>
</div>
)}
</div>
</div>
);
}
// Main shape utility class
export class StripePaymentShapeUtil extends BaseBoxShapeUtil<StripePaymentShape> {
static type = 'stripe-payment' as const;
getDefaultProps(): StripePaymentShape['props'] {
return {
w: 400,
h: 200,
};
}
override canEdit() {
return true;
}
override canResize() {
return false;
}
override onResize(shape: StripePaymentShape) {
return shape;
}
component(shape: StripePaymentShape) {
const [showPopup, setShowPopup] = useState(false);
return (
<HTMLContainer>
<div
style={{
width: shape.props.w,
height: shape.props.h,
padding: '20px',
backgroundColor: '#ffffff',
border: '2px solid #e0e0e0',
borderRadius: '12px',
fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px', color: '#0066cc' }}>💳</div>
<h3 style={{ margin: '0 0 8px 0', fontSize: '20px', fontWeight: '600', color: '#1a1a1a', textAlign: 'center' }}>
Stripe Payment
</h3>
<p style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#666', textAlign: 'center', lineHeight: '1.4' }}>
Click the button below to start your subscription
</p>
<button
onClick={() => setShowPopup(true)}
style={{
padding: '12px 24px',
backgroundColor: '#0066cc',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '16px',
fontWeight: '600',
boxShadow: '0 4px 8px rgba(0, 102, 204, 0.2)',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = '#0052a3';
e.currentTarget.style.transform = 'translateY(-1px)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = '#0066cc';
e.currentTarget.style.transform = 'translateY(0)';
}}
>
Subscribe with Credit Card
</button>
</div>
{showPopup && <StripePaymentPopup onClose={() => setShowPopup(false)} />}
</HTMLContainer>
);
}
indicator(shape: StripePaymentShape) {
return (
<rect
width={shape.props.w}
height={shape.props.h}
rx={8}
ry={8}
fill="none"
stroke="#0066cc"
strokeWidth={2}
/>
);
}
}

7
src/tools/AgentTool.ts Normal file
View File

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from "tldraw"
export class AgentTool extends BaseBoxShapeTool {
static override id = "Agent"
shapeType = "Agent"
override initial = "idle"
}

View File

@ -0,0 +1,7 @@
import { BaseBoxShapeTool } from "tldraw"
export class StripePaymentTool extends BaseBoxShapeTool {
static override id = "stripe-payment"
shapeType = "stripe-payment"
override initial = "idle"
}

View File

@ -100,6 +100,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
<TldrawUiMenuItem {...customActions.unlockElement} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.saveToPdf} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.llm} disabled={!hasSelection} />
<TldrawUiMenuItem {...customActions.createStripePayment} />
</TldrawUiMenuGroup>
{/* Creation Tools Group */}
@ -111,6 +112,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
<TldrawUiMenuItem {...tools.Markdown} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.MycrozineTemplate} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.Prompt} disabled={hasSelection} />
<TldrawUiMenuItem {...tools.StripePayment} disabled={hasSelection} />
</TldrawUiMenuGroup>

View File

@ -168,6 +168,14 @@ export function CustomToolbar() {
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
/>
)}
{tools["stripe-payment"] && (
<TldrawUiMenuItem
{...tools["stripe-payment"]}
icon="credit-card"
label="Stripe Subscription"
isSelected={tools["stripe-payment"].id === editor.getCurrentToolId()}
/>
)}
</DefaultToolbar>
</div>
)

View File

@ -151,6 +151,15 @@ export const overrides: TLUiOverrides = {
readonlyOk: true,
onSelect: () => editor.setCurrentTool("Prompt"),
},
StripePayment: {
id: "stripe-payment",
icon: "credit-card",
label: "Stripe Subscription",
type: "stripe-payment",
kbd: "alt+shift+p",
readonlyOk: true,
onSelect: () => editor.setCurrentTool("stripe-payment"),
},
hand: {
...tools.hand,
onDoubleClick: (info: any) => {
@ -348,6 +357,15 @@ export const overrides: TLUiOverrides = {
}
},
},
createStripePayment: {
id: "create-stripe-payment",
label: "Create Stripe Subscription",
kbd: "alt+shift+s",
readonlyOk: true,
onSelect: () => {
editor.setCurrentTool("stripe-payment")
},
},
//TODO: FIX PREV & NEXT SLIDE KEYBOARD COMMANDS
// "next-slide": {
// id: "next-slide",

View File

@ -25,7 +25,8 @@ export default defineConfig(({ mode }) => {
},
define: {
__WORKER_URL__: JSON.stringify(env.VITE_TLDRAW_WORKER_URL),
__DAILY_API_KEY__: JSON.stringify(env.VITE_DAILY_API_KEY)
__DAILY_API_KEY__: JSON.stringify(env.VITE_DAILY_API_KEY),
__STRIPE_PUBLISHABLE_KEY__: JSON.stringify(env.VITE_STRIPE_PUBLISHABLE_KEY)
}
}
})

View File

@ -19,6 +19,7 @@ import { MarkdownShape } from "@/shapes/MarkdownShapeUtil"
import { MycrozineTemplateShape } from "@/shapes/MycrozineTemplateShapeUtil"
import { SlideShape } from "@/shapes/SlideShapeUtil"
import { PromptShape } from "@/shapes/PromptShapeUtil"
import { StripePaymentShapeUtil } from "@/shapes/stripe/StripePaymentShapeUtil"
// add custom shapes and bindings here if needed:
export const customSchema = createTLSchema({
@ -52,6 +53,9 @@ export const customSchema = createTLSchema({
props: PromptShape.props,
migrations: PromptShape.migrations,
},
'stripe-payment': {
props: StripePaymentShapeUtil.props,
},
},
bindings: defaultBindingSchemas,
})
@ -166,20 +170,55 @@ export class TldrawDurableObject {
serverWebSocket.accept()
const room = await this.getRoom()
// Add connection state tracking
let isConnected = true
// Handle socket connection with proper error boundaries
room.handleSocketConnect({
sessionId,
socket: {
send: serverWebSocket.send.bind(serverWebSocket),
close: serverWebSocket.close.bind(serverWebSocket),
addEventListener:
serverWebSocket.addEventListener.bind(serverWebSocket),
removeEventListener:
serverWebSocket.removeEventListener.bind(serverWebSocket),
send: (message) => {
if (isConnected && serverWebSocket.readyState === WebSocket.OPEN) {
try {
serverWebSocket.send(message)
} catch (error) {
console.error("WebSocket send error:", error)
isConnected = false
}
}
},
close: (code, reason) => {
if (isConnected) {
try {
serverWebSocket.close(code, reason)
} catch (error) {
console.error("WebSocket close error:", error)
} finally {
isConnected = false
}
}
},
addEventListener: (event, listener) => {
serverWebSocket.addEventListener(event, listener)
},
removeEventListener: (event, listener) => {
serverWebSocket.removeEventListener(event, listener)
},
readyState: serverWebSocket.readyState,
},
})
// Add WebSocket event listeners for better error handling
serverWebSocket.addEventListener("error", (event) => {
console.error("WebSocket error:", event)
isConnected = false
})
serverWebSocket.addEventListener("close", (event) => {
console.log("WebSocket closed:", event.code, event.reason)
isConnected = false
})
return new Response(null, {
status: 101,
webSocket: clientWebSocket,
@ -194,7 +233,11 @@ export class TldrawDurableObject {
})
} catch (error) {
console.error("WebSocket connection error:", error)
serverWebSocket.close(1011, "Failed to initialize connection")
try {
serverWebSocket.close(1011, "Failed to initialize connection")
} catch (closeError) {
console.error("Error closing WebSocket:", closeError)
}
return new Response("Failed to establish WebSocket connection", {
status: 500,
})

View File

@ -8,4 +8,6 @@ export interface Environment {
TLDRAW_DURABLE_OBJECT: DurableObjectNamespace
DAILY_API_KEY: string;
DAILY_DOMAIN: string;
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
}

View File

@ -2,10 +2,17 @@ import { handleUnfurlRequest } from "cloudflare-workers-unfurl"
import { AutoRouter, cors, error, IRequest } from "itty-router"
import { handleAssetDownload, handleAssetUpload } from "./assetUploads"
import { Environment } from "./types"
import Stripe from "stripe"
// make sure our sync durable object is made available to cloudflare
export { TldrawDurableObject } from "./TldrawDurableObject"
// Helper function to get price amount
async function getPriceAmount(priceId: string, stripe: Stripe): Promise<number> {
const price = await stripe.prices.retrieve(priceId);
return price.unit_amount || 0;
}
// Define security headers
const securityHeaders = {
"Content-Security-Policy":
@ -325,6 +332,222 @@ const router = AutoRouter<IRequest, [env: Environment, ctx: ExecutionContext]>({
}
})
// Stripe API routes
.post("/api/stripe/create-subscription", async (req, env) => {
try {
const body = await req.json() as {
priceId: string;
customerEmail?: string;
metadata?: Record<string, string>;
};
// Initialize Stripe with your secret key
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2025-05-28.basil',
});
// Create or get customer
let customer;
if (body.customerEmail) {
const existingCustomers = await stripe.customers.list({
email: body.customerEmail,
limit: 1,
});
if (existingCustomers.data.length > 0) {
customer = existingCustomers.data[0];
} else {
customer = await stripe.customers.create({
email: body.customerEmail,
metadata: body.metadata,
});
}
} else {
customer = await stripe.customers.create({
metadata: body.metadata,
});
}
// Create a payment intent for the first payment
const paymentIntent = await stripe.paymentIntents.create({
amount: await getPriceAmount(body.priceId, stripe),
currency: 'cad',
customer: customer.id,
description: `Subscription payment for ${body.metadata?.planName || 'plan'}`,
metadata: {
...body.metadata,
price_id: body.priceId,
customer_id: customer.id,
},
automatic_payment_methods: {
enabled: true,
},
setup_future_usage: 'off_session', // Allow future payments for subscription
});
console.log('Payment intent created:', paymentIntent.id);
return new Response(JSON.stringify({
client_secret: paymentIntent.client_secret,
payment_intent_id: paymentIntent.id,
customer_id: customer.id,
price_id: body.priceId,
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Stripe subscription creation error:', error);
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
})
.post("/api/stripe/create-payment-intent", async (req: Request, env: Environment) => {
try {
const body = await req.json() as {
amount: number;
currency: string;
description: string;
customerEmail?: string;
metadata?: Record<string, string>;
};
// Initialize Stripe with your secret key
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2025-05-28.basil',
});
const paymentIntent = await stripe.paymentIntents.create({
amount: body.amount,
currency: body.currency,
description: body.description,
receipt_email: body.customerEmail,
metadata: body.metadata,
automatic_payment_methods: {
enabled: true,
},
});
return new Response(JSON.stringify({
client_secret: paymentIntent.client_secret,
payment_intent_id: paymentIntent.id,
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: (error as Error).message }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
})
.post("/api/stripe/webhook", async (req: Request, env: Environment) => {
const body = await req.text();
const signature = req.headers.get('stripe-signature');
if (!signature) {
return new Response('No signature', { status: 400 });
}
try {
const stripe = new Stripe(env.STRIPE_SECRET_KEY, {
apiVersion: '2025-05-28.basil',
});
const event = stripe.webhooks.constructEvent(
body,
signature,
env.STRIPE_WEBHOOK_SECRET
);
switch (event.type) {
case 'customer.subscription.created':
const subscription = event.data.object;
console.log('Subscription created:', subscription.id);
await notifySubscriptionCreated(subscription);
break;
case 'customer.subscription.updated':
const updatedSubscription = event.data.object;
console.log('Subscription updated:', updatedSubscription.id);
await notifySubscriptionUpdated(updatedSubscription);
break;
case 'customer.subscription.deleted':
const deletedSubscription = event.data.object;
console.log('Subscription deleted:', deletedSubscription.id);
await notifySubscriptionDeleted(deletedSubscription);
break;
case 'invoice.payment_succeeded':
const invoice = event.data.object;
console.log('Invoice payment succeeded:', invoice.id);
await notifyInvoicePaymentSucceeded(invoice);
break;
case 'invoice.payment_failed':
const failedInvoice = event.data.object;
console.log('Invoice payment failed:', failedInvoice.id);
await notifyInvoicePaymentFailed(failedInvoice);
break;
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
console.log('Payment succeeded:', paymentIntent.id);
await notifyPaymentSuccess(paymentIntent);
break;
case 'payment_intent.payment_failed':
const failedPayment = event.data.object;
console.log('Payment failed:', failedPayment.id);
await notifyPaymentFailure(failedPayment);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return new Response('Webhook handled', { status: 200 });
} catch (error) {
console.error('Webhook error:', error);
return new Response('Webhook error', { status: 400 });
}
})
async function notifySubscriptionCreated(subscription: any) {
console.log('Subscription created notification:', subscription.id);
}
async function notifySubscriptionUpdated(subscription: any) {
console.log('Subscription updated notification:', subscription.id);
}
async function notifySubscriptionDeleted(subscription: any) {
console.log('Subscription deleted notification:', subscription.id);
}
async function notifyInvoicePaymentSucceeded(invoice: any) {
console.log('Invoice payment succeeded notification:', invoice.id);
}
async function notifyInvoicePaymentFailed(invoice: any) {
console.log('Invoice payment failed notification:', invoice.id);
}
async function notifyPaymentSuccess(paymentIntent: any) {
// Implementation depends on your notification system
// Could update database, send email, or trigger real-time updates
console.log('Payment success notification:', paymentIntent.id);
}
async function notifyPaymentFailure(paymentIntent: any) {
// Implementation depends on your notification system
console.log('Payment failure notification:', paymentIntent.id);
}
async function backupAllBoards(env: Environment) {
try {
// List all room files from TLDRAW_BUCKET

View File

@ -8,6 +8,9 @@ account_id = "0e7b3338d5278ed1b148e6456b940913"
# Workers & Pages → jeffemmett-canvas → Settings → Variables
DAILY_DOMAIN = "mycopunks.daily.co"
# Stripe configuration
# These should be set as secrets using `wrangler secret put STRIPE_SECRET_KEY` and `wrangler secret put STRIPE_WEBHOOK_SECRET`
[dev]
port = 5172
ip = "0.0.0.0"