diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..8fe4c7d
--- /dev/null
+++ b/CLAUDE.md
@@ -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
\ No newline at end of file
diff --git a/STRIPE_SETUP.md b/STRIPE_SETUP.md
new file mode 100644
index 0000000..37d4f2f
--- /dev/null
+++ b/STRIPE_SETUP.md
@@ -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
\ No newline at end of file
diff --git a/index.html b/index.html
index a3ec45f..fe798ed 100644
--- a/index.html
+++ b/index.html
@@ -1,5 +1,5 @@
-
+
Jeff Emmett
@@ -33,6 +33,23 @@
+
+
diff --git a/package-lock.json b/package-lock.json
index 3334d41..0ed240c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index b6801a8..4cd20f3 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/src/routes/Board.tsx b/src/routes/Board.tsx
index d67f9a9..34166c8 100644
--- a/src/routes/Board.tsx
+++ b/src/routes/Board.tsx
@@ -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 (
{
setEditor(editor)
+ // Expose editor globally for Stripe component access
+ ;(window as any).__TLDRAW_EDITOR__ = editor
editor.registerExternalAssetHandler("url", unfurlBookmarkUrl)
editor.setCurrentTool("hand")
setInitialCameraFromUrl(editor)
diff --git a/src/shapes/MarkdownShapeUtil.tsx b/src/shapes/MarkdownShapeUtil.tsx
index 565d1a5..5dcc574 100644
--- a/src/shapes/MarkdownShapeUtil.tsx
+++ b/src/shapes/MarkdownShapeUtil.tsx
@@ -112,15 +112,15 @@ export class MarkdownShape extends BaseBoxShapeUtil {
}}
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 {
style: {
padding: '12px',
lineHeight: '1.5',
- height: 'auto',
- minHeight: '100%',
resize: 'none',
backgroundColor: 'transparent',
}
@@ -137,6 +135,8 @@ export class MarkdownShape extends BaseBoxShapeUtil {
onPointerDown={(e) => {
e.stopPropagation()
}}
+ hideToolbar={false}
+ enableScroll={true}
/>
diff --git a/src/shapes/stripe/ModalManager.tsx b/src/shapes/stripe/ModalManager.tsx
new file mode 100644
index 0000000..e97f594
--- /dev/null
+++ b/src/shapes/stripe/ModalManager.tsx
@@ -0,0 +1,33 @@
+import React, { useState } from 'react';
+import { StripePaymentPopup } from './StripePaymentShapeUtil';
+
+export function ModalManager() {
+ const [isOpen, setIsOpen] = useState(false);
+
+ if (isOpen) {
+ return setIsOpen(false)} />;
+ }
+
+ return (
+ 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
+
+ );
+}
diff --git a/src/shapes/stripe/StripePaymentShape.ts b/src/shapes/stripe/StripePaymentShape.ts
new file mode 100644
index 0000000..35639f6
--- /dev/null
+++ b/src/shapes/stripe/StripePaymentShape.ts
@@ -0,0 +1,9 @@
+import { TLBaseShape } from 'tldraw';
+
+export type StripePaymentShape = TLBaseShape<
+ 'stripe-payment',
+ {
+ w: number;
+ h: number;
+ }
+>;
\ No newline at end of file
diff --git a/src/shapes/stripe/StripePaymentShapeUtil.tsx b/src/shapes/stripe/StripePaymentShapeUtil.tsx
new file mode 100644
index 0000000..28a3a59
--- /dev/null
+++ b/src/shapes/stripe/StripePaymentShapeUtil.tsx
@@ -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(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
+
Stripe configuration error. Please check your setup.
+
;
+ }
+
+ const stripePromise = loadStripe(__STRIPE_PUBLISHABLE_KEY__);
+
+ return (
+
+
+
+ );
+}
+
+// Stripe Payment Popup Component
+export function StripePaymentPopup({ onClose }: { onClose: () => void }) {
+ const [selectedPlanId, setSelectedPlanId] = useState('price_1RdDMgKFe1dC1xn7p319KRDU');
+ const [customerEmail, setCustomerEmail] = useState('');
+ const [clientSecret, setClientSecret] = useState(null);
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const mountedRef = useRef(true);
+ const abortControllerRef = useRef(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 (
+
+
+
+ ×
+
+
+ {!clientSecret ? (
+
+
+ Choose Your Plan
+
+
+
+ {SUBSCRIPTION_PLANS.map((plan) => (
+
setSelectedPlanId(plan.id)}
+ >
+
+ {plan.name}
+
+
+ ${(plan.price / 100).toFixed(2)}/{plan.interval}
+
+
+ {plan.description}
+
+
+ {plan.features.map((feature, index) => (
+ {feature}
+ ))}
+
+
+ ))}
+
+
+
+
+
+ Email Address
+
+ 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',
+ }}
+ />
+
+
+
+ {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`}
+
+
+
+ ) : (
+
+ )}
+
+ {error && (
+
+
⚠️
+
+ {error}
+
+
{ 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
+
+
+ )}
+
+
+ );
+}
+
+// Main shape utility class
+export class StripePaymentShapeUtil extends BaseBoxShapeUtil {
+ 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 (
+
+
+
💳
+
+ Stripe Payment
+
+
+ Click the button below to start your subscription
+
+
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
+
+
+ {showPopup && setShowPopup(false)} />}
+
+ );
+ }
+
+ indicator(shape: StripePaymentShape) {
+ return (
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/src/tools/AgentTool.ts b/src/tools/AgentTool.ts
new file mode 100644
index 0000000..799e410
--- /dev/null
+++ b/src/tools/AgentTool.ts
@@ -0,0 +1,7 @@
+import { BaseBoxShapeTool } from "tldraw"
+
+export class AgentTool extends BaseBoxShapeTool {
+ static override id = "Agent"
+ shapeType = "Agent"
+ override initial = "idle"
+}
diff --git a/src/tools/StripePaymentTool.ts b/src/tools/StripePaymentTool.ts
new file mode 100644
index 0000000..887f6c5
--- /dev/null
+++ b/src/tools/StripePaymentTool.ts
@@ -0,0 +1,7 @@
+import { BaseBoxShapeTool } from "tldraw"
+
+export class StripePaymentTool extends BaseBoxShapeTool {
+ static override id = "stripe-payment"
+ shapeType = "stripe-payment"
+ override initial = "idle"
+}
\ No newline at end of file
diff --git a/src/ui/CustomContextMenu.tsx b/src/ui/CustomContextMenu.tsx
index f5bd02e..aeaa46c 100644
--- a/src/ui/CustomContextMenu.tsx
+++ b/src/ui/CustomContextMenu.tsx
@@ -100,6 +100,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
+
{/* Creation Tools Group */}
@@ -111,6 +112,7 @@ export function CustomContextMenu(props: TLUiContextMenuProps) {
+
diff --git a/src/ui/CustomToolbar.tsx b/src/ui/CustomToolbar.tsx
index 8b98576..e706c78 100644
--- a/src/ui/CustomToolbar.tsx
+++ b/src/ui/CustomToolbar.tsx
@@ -168,6 +168,14 @@ export function CustomToolbar() {
isSelected={tools["Prompt"].id === editor.getCurrentToolId()}
/>
)}
+ {tools["stripe-payment"] && (
+
+ )}
)
diff --git a/src/ui/overrides.tsx b/src/ui/overrides.tsx
index 61ed742..9caebef 100644
--- a/src/ui/overrides.tsx
+++ b/src/ui/overrides.tsx
@@ -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",
diff --git a/vite.config.ts b/vite.config.ts
index a84e7e1..d65b46d 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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)
}
}
})
diff --git a/worker/TldrawDurableObject.ts b/worker/TldrawDurableObject.ts
index add19fe..b84dba3 100644
--- a/worker/TldrawDurableObject.ts
+++ b/worker/TldrawDurableObject.ts
@@ -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,
})
diff --git a/worker/types.ts b/worker/types.ts
index 15313f1..66c4d69 100644
--- a/worker/types.ts
+++ b/worker/types.ts
@@ -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;
}
\ No newline at end of file
diff --git a/worker/worker.ts b/worker/worker.ts
index ac2d0f0..01ade0d 100644
--- a/worker/worker.ts
+++ b/worker/worker.ts
@@ -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 {
+ 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({
}
})
+ // Stripe API routes
+ .post("/api/stripe/create-subscription", async (req, env) => {
+ try {
+ const body = await req.json() as {
+ priceId: string;
+ customerEmail?: string;
+ metadata?: Record;
+ };
+
+ // 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;
+ };
+
+ // 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
diff --git a/wrangler.toml b/wrangler.toml
index a5f8322..e468d81 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -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"