Integrate Stripe Checkout and add comprehensive UI enhancements
All checks were successful
Build and Push Docker Image / docker (push) Successful in 42s

## Stripe Payment Integration
- Add Express.js backend server with Stripe Checkout Sessions
- Create SQLite database for order tracking
- Implement Stripe webhook handlers for payment events
- Integrate with Wren Climate API for carbon offset fulfillment
- Add CheckoutSuccess and CheckoutCancel pages
- Create checkout API client for frontend
- Update OffsetOrder component to redirect to Stripe Checkout
- Add processing fee calculation (3% of base amount)
- Implement order status tracking (pending → paid → fulfilled)

Backend (server/):
- Express server with CORS and middleware
- SQLite database with Order schema
- Stripe configuration and client
- Order CRUD operations model
- Checkout session creation endpoint
- Webhook handler for payment confirmation
- Wren API client for offset fulfillment

Frontend:
- CheckoutSuccess page with order details display
- CheckoutCancel page with retry encouragement
- Updated OffsetOrder to use Stripe checkout flow
- Added checkout routes to App.tsx
- TypeScript interfaces for checkout flow

## Visual & UX Enhancements
- Add CertificationBadge component for project verification status
- Create PortfolioDonutChart for visual portfolio allocation
- Implement RadialProgress for percentage displays
- Add reusable form components (FormInput, FormTextarea, FormSelect, FormFieldWrapper)
- Refactor OffsetOrder with improved layout and animations
- Add offset percentage slider with visual feedback
- Enhance MobileOffsetOrder with better responsive design
- Improve TripCalculator with cleaner UI structure
- Update CurrencySelect with better styling
- Add portfolio distribution visualization
- Enhance project cards with hover effects and animations
- Improve color palette and gradient usage throughout

## Configuration
- Add VITE_API_BASE_URL environment variable
- Create backend .env.example template
- Update frontend .env.example with API URL
- Add Stripe documentation references

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Matt 2025-10-29 21:45:14 +01:00
parent 3a33221130
commit 06733cb2cb
34 changed files with 18959 additions and 654 deletions

View File

@ -1,3 +1,8 @@
VITE_WREN_API_TOKEN=your-token-here
VITE_FORMSPREE_CONTACT_ID=your-formspree-contact-form-id
VITE_FORMSPREE_OFFSET_ID=your-formspree-offset-form-id
VITE_FORMSPREE_OFFSET_ID=your-formspree-offset-form-id
# Backend API URL (for Stripe checkout)
# Development: http://localhost:3001
# Production: https://api.puffinoffset.com (or your backend URL)
VITE_API_BASE_URL=http://localhost:3001

14250
docs/stripe-api-setup.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,509 @@
# Elements Appearance API
Customize the look and feel of Elements to match the design of your site.
Stripe Elements supports visual customization, which allows you to match the design of your site with the `appearance` option. The layout of each Element stays consistent, but you can modify colors, fonts, borders, padding, and more.
1. Pick a prebuilt [theme](https://docs.stripe.com/elements/appearance-api.md#theme) that most closely resembles your website.
1. Customize the theme using [inputs and labels](https://docs.stripe.com/elements/appearance-api.md#inputs-and-labels). You can also set [variables](https://docs.stripe.com/elements/appearance-api.md#variables), such as the `fontFamily` and `colorPrimary` to broadly customize components appearing throughout each Element.
1. If needed, fine-tune individual components and states using [rules](https://docs.stripe.com/elements/appearance-api.md#rules).
For complete control, specify custom CSS properties for individual components appearing in the Element.
> The Elements Appearance API doesnt support individual payment method Elements (such as `CardElement`). Use the [Style](https://docs.stripe.com/js/appendix/style) object to customize your Element instead.
## Themes
Start customizing Elements by selecting one of the following themes:
- `stripe`
- `night`
- `flat`
#### Checkout Sessions API
```js
const appearance = {
theme: 'night'
};
// Pass the appearance object when initializing checkout
const checkout = stripe.initCheckout({clientSecret, elementsOptions: {appearance}});
```
#### Payment Intents API
```js
const appearance = {
theme: 'night'
};
// Pass the appearance object to the Elements instance
const elements = stripe.elements({clientSecret, appearance});
```
## Inputs and labels
Customize the appearance of input fields and their associated labels.
`const appearance = {
inputs: 'spaced',
labels: 'auto'
}`
### Inputs
Choose the style of input fields to suit your design.
| Variant | Description |
| ----------- | ---------------------------------------------------------------------- |
| `spaced` | Each input field has space surrounding it. This is the default option. |
| `condensed` | Related input fields are grouped together without space between them. |
### Labels
Control the position and visibility of labels associated with input fields.
| Variant | Description |
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto` | Labels adjust based on the input variant. When inputs are `spaced`, labels are `above`. When inputs are `condensed`, labels are `floating`. This is the default option. |
| `above` | Labels are positioned above the corresponding input fields. |
| `floating` | Labels float within the input fields. |
## Variables
Set variables to affect the appearance of many components appearing throughout each Element.
![Payment form with card input fields, major credit card icons, and Klarna payment option, with labeled Appearance API variables for colors and styling.](https://b.stripecdn.com/docs-statics-srv/assets/exampleVariables@2x.8c50d1561d5d4fbb1ac0187983ab33c0.png)
The `variables` option works like [CSS variables](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties). You can specify CSS values for each variable and reference other variables with the `var(--myVariable)` syntax. You can even inspect the resulting DOM using the DOM explorer in your browser.
#### Checkout Sessions API
```js
const appearance = {
theme: 'stripe',
variables: {
colorPrimary: '#0570de',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: 'Ideal Sans, system-ui, sans-serif',
spacingUnit: '2px',
borderRadius: '4px',
// See all possible variables below
}
};
// Pass the appearance object when initializing checkout
const checkout = stripe.initCheckout({clientSecret, elementsOptions: {appearance}});
```
#### Payment Intents API
```js
const appearance = {
theme: 'stripe',
variables: {
colorPrimary: '#0570de',
colorBackground: '#ffffff',
colorText: '#30313d',
colorDanger: '#df1b41',
fontFamily: 'Ideal Sans, system-ui, sans-serif',
spacingUnit: '2px',
borderRadius: '4px',
// See all possible variables below
}
};
// Pass the appearance object to the Elements instance
const elements = stripe.elements({clientSecret, appearance});
```
### Commonly used variables
| Variable | Description |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fontFamily` | The font family used throughout Elements. Elements supports custom fonts by passing the `fonts` option to the [Elements group](https://docs.stripe.com/js/elements_object/create#stripe_elements-options-fonts). |
| `fontSizeBase` | The font size thats set on the root of the Element. By default, other font size variables such as `fontSizeXs` or `fontSizeSm` are scaled from this value using `rem` units. Make sure that you choose a font size of at least 16px for input fields on mobile. |
| `spacingUnit` | The base spacing unit that all other spacing is derived from. Increase or decrease this value to make your layout more or less spacious. |
| `borderRadius` | The border radius used for tabs, inputs, and other components in the Element. |
| `colorPrimary` | A primary color used throughout the Element. Set this to your primary brand color. |
| `colorBackground` | The color used for the background of inputs, tabs, and other components in the Element. |
| `colorText` | The default text color used in the Element. |
| `colorDanger` | A color used to indicate errors or destructive actions in the Element. |
### Less commonly used variables
| Variable | Description |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `buttonBorderRadius` | The border radius used for buttons. By default, buttons use `borderRadius`. |
| `fontSmooth` | What text anti-aliasing settings to use in the Element. It can be `always`, `auto`, or `never`. |
| `fontVariantLigatures` | The [font-variant-ligatures](http://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-ligatures) setting of text in the Element. |
| `fontVariationSettings` | The [font-variation-settings](http://developer.mozilla.org/en-US/docs/Web/CSS/font-variation-settings) setting of text in the Element. |
| `fontWeightLight` | The font weight used for light text. |
| `fontWeightNormal` | The font weight used for normal text. |
| `fontWeightMedium` | The font weight used for medium text. |
| `fontWeightBold` | The font weight used for bold text. |
| `fontLineHeight` | The [line-height](http://developer.mozilla.org/en-US/docs/Web/CSS/line-height) setting of text in the Element. |
| `fontSizeXl` | The font size of extra-large text in the Element. By default this is scaled from `var(--fontSizeBase)` using `rem` units. |
| `fontSizeLg` | The font size of large text in the Element. By default this is scaled from `var(--fontSizeBase)` using `rem` units. |
| `fontSizeSm` | The font size of small text in the Element. By default this is scaled from `var(--fontSizeBase)` using `rem` units. |
| `fontSizeXs` | The font size of extra-small text in the Element. By default this is scaled from `var(--fontSizeBase)` using `rem` units. |
| `fontSize2Xs` | The font size of double-extra small text in the Element. By default this is scaled from `var(--fontSizeBase)` using `rem` units. |
| `fontSize3Xs` | The font size of triple-extra small text in the Element. By default this is scaled from `var(--fontSizeBase)` using `rem` units. |
| `logoColor` | A preference for which logo variations to display; either `light` or `dark`. |
| `tabLogoColor` | The logo variation to display inside `.Tab` components; either `light` or `dark`. |
| `tabLogoSelectedColor` | The logo variation to display inside the `.Tab--selected` component; either `light` or `dark`. |
| `blockLogoColor` | The logo variation to display inside `.Block` components; either `light` or `dark`. |
| `colorSuccess` | A color used to indicate positive actions or successful results in the Element. |
| `colorWarning` | A color used to indicate potentially destructive actions in the Element. |
| `accessibleColorOnColorPrimary` | The color of text appearing on top of any `var(--colorPrimary)` background. |
| `accessibleColorOnColorBackground` | The color of text appearing on top of any `var(--colorBackground)` background. |
| `accessibleColorOnColorSuccess` | The color of text appearing on top of any `var(--colorSuccess)` background. |
| `accessibleColorOnColorDanger` | The color of text appearing on top of any `var(--colorDanger)` background. |
| `accessibleColorOnColorWarning` | The color of text appearing on top of any `var(--colorWarning)` background. |
| `colorTextSecondary` | The color used for text of secondary importance. For example, this color is used for the label of a tab that isnt currently selected. |
| `colorTextPlaceholder` | The color used for input placeholder text in the Element. |
| `iconColor` | The default color used for icons in the Element, such as the icon appearing in the card tab. |
| `iconHoverColor` | The color of icons when hovered. |
| `iconCardErrorColor` | The color of the card icon when its in an error state. |
| `iconCardCvcColor` | The color of the CVC variant of the card icon. |
| `iconCardCvcErrorColor` | The color of the CVC variant of the card icon when the CVC field has invalid input. |
| `iconCheckmarkColor` | The color of checkmarks displayed within components like `.Checkbox`. |
| `iconChevronDownColor` | The color of arrow icons displayed within select inputs. |
| `iconChevronDownHoverColor` | The color of arrow icons when hovered. |
| `iconCloseColor` | The color of close icons, used for indicating a dismissal or close action. |
| `iconCloseHoverColor` | The color of close icons when hovered. |
| `iconLoadingIndicatorColor` | The color of the spinner in loading indicators. |
| `iconMenuColor` | The color of menu icons used to indicate a set of additional actions. |
| `iconMenuHoverColor` | The color of menu icons when hovered. |
| `iconMenuOpenColor` | The color of menu icons when opened. |
| `iconPasscodeDeviceColor` | The color of the passcode device icon, used to indicate a message has been sent to the users mobile device. |
| `iconPasscodeDeviceHoverColor` | The color of the passcode device icon when hovered. |
| `iconPasscodeDeviceNotificationColor` | The color of the notification indicator displayed over the passcode device icon. |
| `iconRedirectColor` | The color of the redirect icon that appears for redirect-based payment methods. |
| `tabIconColor` | The color of icons appearing in a tab. |
| `tabIconHoverColor` | The color of icons appearing in a tab when the tab is hovered. |
| `tabIconSelectedColor` | The color of icons appearing in a tab when the tab is selected. |
| `tabIconMoreColor` | The color of the icon that appears in the trigger for the additional payment methods menu. |
| `tabIconMoreHoverColor` | The color of the icon that appears in the trigger for the additional payment methods menu when the trigger is hovered. |
| `accordionItemSpacing` | The vertical spacing between `.AccordionItem` components. This is only applicable when [spacedAccordionItems](https://docs.stripe.com/js/elements_object/create_payment_element#payment_element_create-options-layout-spacedAccordionItems) is `true`. |
| `gridColumnSpacing` | The spacing between columns in the grid used for the Element layout. |
| `gridRowSpacing` | The spacing between rows in the grid used for the Element layout. |
| `pickerItemSpacing` | The spacing between `.PickerItem` components rendered within the `.Picker` component. |
| `tabSpacing` | The horizontal spacing between `.Tab` components. |
## Rules
The `rules` option is a map of CSS-like selectors to CSS properties, allowing granular customization of individual components. After defining your `theme` and `variables`, use `rules` to seamlessly integrate Elements to match the design of your site.
#### Checkout Session API
```js
const appearance = {
rules: {
'.Tab': {
border: '1px solid #E0E6EB',
boxShadow: '0px 1px 1px rgba(0, 0, 0, 0.03), 0px 3px 6px rgba(18, 42, 66, 0.02)',
},
'.Tab:hover': {
color: 'var(--colorText)',
},
'.Tab--selected': {
borderColor: '#E0E6EB',
boxShadow: '0px 1px 1px rgba(0, 0, 0, 0.03), 0px 3px 6px rgba(18, 42, 66, 0.02), 0 0 0 2px var(--colorPrimary)',
},
'.Input--invalid': {
boxShadow: '0 1px 1px 0 rgba(0, 0, 0, 0.07), 0 0 0 2px var(--colorDanger)',
},
// See all supported class names and selector syntax below
}
};
// Pass the appearance object when initializing checkout
const checkout = stripe.initCheckout({clientSecret, elementsOptions: {appearance}});
```
#### Payment Intents API
```js
const appearance = {
rules: {
'.Tab': {
border: '1px solid #E0E6EB',
boxShadow: '0px 1px 1px rgba(0, 0, 0, 0.03), 0px 3px 6px rgba(18, 42, 66, 0.02)',
},
'.Tab:hover': {
color: 'var(--colorText)',
},
'.Tab--selected': {
borderColor: '#E0E6EB',
boxShadow: '0px 1px 1px rgba(0, 0, 0, 0.03), 0px 3px 6px rgba(18, 42, 66, 0.02), 0 0 0 2px var(--colorPrimary)',
},
'.Input--invalid': {
boxShadow: '0 1px 1px 0 rgba(0, 0, 0, 0.07), 0 0 0 2px var(--colorDanger)',
},
// See all supported class names and selector syntax below
}
};
// Pass the appearance object to the Elements instance
const elements = stripe.elements({clientSecret, appearance});
```
### All rules
The selector for a rule can target any of the public class names in the Element, as well as the supported states, pseudo-classes, and pseudo-elements for each class. For example, the following are valid selectors:
- `.Tab, .Label, .Input`
- `.Tab:focus`
- `.Input--invalid, .Label--invalid`
- `.Input::placeholder`
The following are **not** valid selectors:
- `.p-SomePrivateClass, img`, only public class names can be targeted
- `.Tab .TabLabel`, ancestor-descendant relationships in selectors are unsupported
- `.Tab--invalid`, the `.Tab` class does not support the `--invalid` state
Each class name used in a selector [supports an allowlist of CSS properties](https://docs.stripe.com/elements/appearance-api.md#supported-css-properties), that you specify using camel case (for example, `boxShadow` for the [box-shadow](https://developer.mozilla.org/en-US/docs/Web/CSS/box-shadow) property).
The following is the complete list of supported class names and corresponding states, pseudo-classes, and pseudo-elements.
### Tabs
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesTabs@2x.9c36db7ee4c98d7b2d6f00e91e6d4f20.png)
| Class name | States | Pseudo-classes | Pseudo-elements |
| ----------- | ------------ | ------------------------------------------ | --------------- |
| `.Tab` | `--selected` | `:hover`, `:focus`, `:active`, `:disabled` | |
| `.TabIcon` | `--selected` | `:hover`, `:focus`, `:active`, `:disabled` | |
| `.TabLabel` | `--selected` | `:hover`, `:focus`, `:active`, `:disabled` | |
### Inputs (above labels)
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesFormInputs@2x.4ed082ee74fcbad043a80e2d4b133b35.png)
Make sure that you choose a font size of at least 16px for input fields on mobile.
| Class name | States | Pseudo-classes | Pseudo-elements |
| ---------- | ----------------------------------- | -------------------------------------------- | ------------------------------ |
| `.Label` | `--empty`, `--invalid`, `--focused` | | |
| `.Input` | `--empty`, `--invalid` | `:hover`, `:focus`, `:disabled`, `:autofill` | `::placeholder`, `::selection` |
| `.Error` | | | |
### Inputs (floating labels)
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesFormInputsFloating@2x.daec4a823ac24cc86d94a44664104eb8.png)
> You can enable floating labels as an [additional configuration option](https://docs.stripe.com/elements/appearance-api.md#others).
| Class name | States | Pseudo-classes | Pseudo-elements |
| ---------- | -------------------------------------------------------------- | -------------------------------------------- | ------------------------------ |
| `.Label` | `--empty`, `--invalid`, `--focused`, `--floating`, `--resting` | | |
| `.Input` | `--empty`, `--invalid` | `:hover`, `:focus`, `:disabled`, `:autofill` | `::placeholder`, `::selection` |
| `.Error` | | | |
### Block
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesBlock@2x.556532f7e919aaf1d775ceb0253f5c22.png)
| Class name | States | Pseudo-classes | Pseudo-elements |
| --------------- | ------------ | ----------------------------- | --------------- |
| `.Block` | | | |
| `.BlockDivider` | | | |
| `.BlockAction` | `--negative` | `:hover`, `:focus`, `:active` | |
### Code Input
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesCodeInput@2x.64975e4945d393068a2f207a2d48f25c.png)
| Class name | States | Pseudo-classes | Pseudo-elements |
| ------------ | ------ | ------------------------------- | --------------- |
| `.CodeInput` | | `:hover`, `:focus`, `:disabled` | |
### Checkbox
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesCheckbox@2x.d7bedd38a342344eb06d5bff5dd6ae43.png)
| Class name | States | Pseudo-classes | Pseudo-elements |
| ---------------- | ----------- | ------------------------------------ | --------------- |
| `.Checkbox` | `--checked` | | |
| `.CheckboxLabel` | `--checked` | `:hover`, `:focus`, `:focus-visible` | |
| `.CheckboxInput` | `--checked` | `:hover`, `:focus`, `:focus-visible` | |
### Dropdown
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesDropdown@2x.d635e032d2a254d672c11825a2d3d23d.png)
| Class name | States | Pseudo-classes | Pseudo-elements |
| --------------- | ------------- | -------------- | --------------- |
| `.Dropdown` | | | |
| `.DropdownItem` | `--highlight` | `:active` | |
> Dropdown styling is limited on macOS. The appearance API for dropdowns primarily affects Windows systems. On macOS, you cant style system dropdowns, such as the country selector, using these rules because of operating system restrictions.
### Switch
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesSwitch@2x.a263ba8361af937d5228a35d18c63645.png)
| Class name | States | Pseudo-classes | Pseudo-elements |
| ---------------- | ---------- | -------------- | --------------- |
| `.Switch` | `--active` | `:hover` | |
| `.SwitchControl` | | `:hover` | |
### Picker
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesPicker@2x.aa78c665be0c7e33a62992c8e7e33014.png)
| Class name | States | Pseudo-classes | Pseudo-elements |
| --------------- | -------------------------------------------------- | ----------------------------- | --------------- |
| `.PickerItem` | `--selected`, `--highlight`, `--new`, `--disabled` | `:hover`, `:focus`, `:active` | |
| `.PickerAction` | | `:hover`, `:focus`, `:active` | |
Make sure your `.PickerItem` active state stands out from the other states.
| ![](https://b.stripecdn.com/docs-statics-srv/assets/uxTipPickerDo@2x.cc709dc96a8e99e6b020f53216d4d585.png) | ![](https://b.stripecdn.com/docs-statics-srv/assets/uxTipPickerDont@2x.b31bc4b51910a6eece59d44fa92c5b4d.png) |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **DO**
Use a noticeable, high-contrast primary color, weight, and/or outline to distinguish the active state your customer has already selected. | **DONT**
Dont use two equally weighted options or low-contrast colors for your .PickerItem states because it makes distinguishing which one is active more difficult. |
### Menu
| Class name | States | Pseudo-classes | Pseudo-elements |
| ------------- | ------------ | ----------------------------- | --------------- |
| `.Menu` | | | |
| `.MenuIcon` | `--open` | `:hover` | |
| `.MenuAction` | `--negative` | `:hover`, `:focus`, `:active` | |
### Accordion
| Class name | States | Pseudo-classes | Pseudo-elements |
| ---------------- | ------------ | -------------------------- | --------------- |
| `.AccordionItem` | `--selected` | `:hover`, `:focus-visible` | |
### Payment Method Messaging Element
| Class name | States | Pseudo-classes | Pseudo-elements |
| ------------------------- | ------ | -------------- | --------------- |
| `.PaymentMethodMessaging` | | | |
### Radio Icon
![](https://b.stripecdn.com/docs-statics-srv/assets/exampleRulesRadioIcon@2x.25886d6b352ac0a8d003e7e2cd39677d.png)
| Class name | States | Pseudo-classes | Pseudo-elements |
| ----------------- | ------------------------ | -------------- | --------------- |
| `.RadioIcon` | | | |
| `.RadioIconOuter` | `--checked`, `--hovered` | | |
| `.RadioIconInner` | `--checked`, `--hovered` | | |
You can control the overall size of the icon with the `width` property on `.RadioIcon`. You can control the relative size of `.RadioIconInner` with the `r` (radius) property. `.RadioIconOuter` and `.RadioIconInner` are SVG elements and can be styled with `stroke` and `fill` properties. See the full list of [supported CSS properties](https://docs.stripe.com/elements/appearance-api.md#supported-css-properties) below.
```js
const appearance = {
rules: {
'.RadioIcon': {
width: '24px'
},
'.RadioIconOuter': {
stroke: '#E0E6EB'
},
'.RadioIconInner': {
r: '16'
}
}
};
```
### Supported CSS properties
| CSS Property | Supported classes |
| ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `-moz-osx-font-smoothing` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `-webkit-font-smoothing` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `-webkit-text-fill-color` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `backgroundColor` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `BlockDivider`, `Button`, `CheckboxInput`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `InputDivider`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `Switch`, `Tab`, `ToggleItem` |
| `border` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderBottom` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderBottomColor` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderBottomLeftRadius` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderBottomRightRadius` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderBottomStyle` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderBottomWidth` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderColor` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderLeft` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderLeftColor` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderLeftStyle` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderLeftWidth` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderRadius` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `InputCloseIcon`, `Link`, `MenuAction`, `MenuIcon`, `PasscodeCloseIcon`, `PasscodeShowIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Switch`, `SwitchControl`, `Tab`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `borderRight` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderRightColor` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderRightStyle` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderRightWidth` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderStyle` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderTop` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderTopColor` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderTopLeftRadius` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderTopRightRadius` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderTopStyle` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderTopWidth` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `borderWidth` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Switch`, `SwitchControl`, `Tab`, `TermsText`, `Text`, `ToggleItem` |
| `boxShadow` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `InputCloseIcon`, `Link`, `MenuAction`, `MenuIcon`, `PasscodeCloseIcon`, `PasscodeShowIcon`, `PickerAction`, `PickerItem`, `SecondaryLink`, `Switch`, `SwitchControl`, `Tab`, `TermsLink`, `ToggleItem` |
| `color` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabIcon`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `fill` | `Action`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RadioIconInner`, `RadioIconOuter`, `SwitchControl`, `Tab`, `TabIcon`, `ToggleItem` |
| `fillOpacity` | `RadioIconInner`, `RadioIconOuter` |
| `fontFamily` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `fontSize` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Switch`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `fontVariant` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `fontWeight` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `letterSpacing` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `lineHeight` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `margin` | `Action`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `MenuAction`, `PickerAction`, `PickerItem`, `Tab`, `ToggleItem` |
| `marginBottom` | `Action`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `MenuAction`, `PickerAction`, `PickerItem`, `Tab`, `ToggleItem` |
| `marginLeft` | `Action`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `MenuAction`, `PickerAction`, `PickerItem`, `Tab`, `ToggleItem` |
| `marginRight` | `Action`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `MenuAction`, `PickerAction`, `PickerItem`, `Tab`, `ToggleItem` |
| `marginTop` | `Action`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `MenuAction`, `PickerAction`, `PickerItem`, `Tab`, `ToggleItem` |
| `opacity` | `Label` |
| `outline` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `InputCloseIcon`, `Link`, `MenuAction`, `MenuIcon`, `PasscodeCloseIcon`, `PasscodeShowIcon`, `PickerAction`, `PickerItem`, `SecondaryLink`, `Switch`, `SwitchControl`, `Tab`, `TermsLink`, `ToggleItem` |
| `outlineOffset` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Input`, `InputCloseIcon`, `Link`, `MenuAction`, `MenuIcon`, `PasscodeCloseIcon`, `PasscodeShowIcon`, `PickerAction`, `PickerItem`, `SecondaryLink`, `Switch`, `SwitchControl`, `Tab`, `TermsLink`, `ToggleItem` |
| `padding` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Menu`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Tab`, `TabIcon`, `TabLabel`, `TermsText`, `Text`, `ToggleItem` |
| `paddingBottom` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Menu`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Tab`, `TabIcon`, `TabLabel`, `TermsText`, `Text`, `ToggleItem` |
| `paddingLeft` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Menu`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Tab`, `TabIcon`, `TabLabel`, `TermsText`, `Text`, `ToggleItem` |
| `paddingRight` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Menu`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Tab`, `TabIcon`, `TabLabel`, `TermsText`, `Text`, `ToggleItem` |
| `paddingTop` | `AccordionItem`, `Action`, `Block`, `BlockAction`, `Button`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Menu`, `MenuAction`, `MenuIcon`, `PickerAction`, `PickerItem`, `RedirectText`, `Tab`, `TabIcon`, `TabLabel`, `TermsText`, `Text`, `ToggleItem` |
| `r` | `RadioIconInner` |
| `stroke` | `RadioIconInner`, `RadioIconOuter` |
| `strokeOpacity` | `RadioIconInner`, `RadioIconOuter` |
| `strokeWidth` | `RadioIconInner`, `RadioIconOuter` |
| `textAlign` | `PaymentMethodMessaging` |
| `textDecoration` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `textShadow` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `textTransform` | `AccordionItem`, `Action`, `BlockAction`, `Button`, `Checkbox`, `CheckboxLabel`, `CodeInput`, `DropdownItem`, `Error`, `Input`, `Label`, `Link`, `MenuAction`, `PickerAction`, `PickerItem`, `RedirectText`, `SecondaryLink`, `Tab`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `transition` | `Action`, `Block`, `BlockAction`, `Button`, `CheckboxInput`, `CheckboxLabel`, `CodeInput`, `Dropdown`, `DropdownItem`, `Error`, `Icon`, `Input`, `InputCloseIcon`, `Label`, `Link`, `MenuAction`, `MenuIcon`, `PasscodeCloseIcon`, `PasscodeShowIcon`, `PickerAction`, `PickerItem`, `RadioIconInner`, `RadioIconOuter`, `RedirectText`, `SecondaryLink`, `Switch`, `SwitchControl`, `Tab`, `TabIcon`, `TabLabel`, `TermsLink`, `TermsText`, `Text`, `ToggleItem` |
| `width` | `RadioIcon` |
Some exceptions to the properties above are:
- `-webkit-text-fill-color` isnt compatible with pseudo-classes
## Other configuration options
In addition to `themes`, `labels`, `inputs`, `variables` and `rules`, you can style Elements using other appearance configuration options.
You can customize these by adding them to the appearance object:
```js
const appearance = {
disableAnimations: true,
// other configurations such as `theme`, `labels`, `inputs`, `variables` and `rules`...
}
```
We currently support the below options:
| Configuration | Description |
| ------------------- | ---------------------------------------------------------------------- |
| `disableAnimations` | Disables animations throughout Elements. Boolean, defaults to `false`. |

15
server/.env.example Normal file
View File

@ -0,0 +1,15 @@
# Stripe Configuration
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Wren Climate API
WREN_API_TOKEN=35c025d9-5dbb-404b-85aa-19b09da0578d
# Server Configuration
PORT=3001
# Development: http://localhost:5173
# Production: https://puffinoffset.com
FRONTEND_URL=https://puffinoffset.com
# Database
DATABASE_PATH=./orders.db

9
server/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules/
.env
*.db
*.db-shm
*.db-wal
.DS_Store
npm-debug.log*
yarn-debug.log*
yarn-error.log*

73
server/config/database.js Normal file
View File

@ -0,0 +1,73 @@
import Database from 'better-sqlite3';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dbPath = process.env.DATABASE_PATH || join(__dirname, '..', 'orders.db');
// Initialize database
export const db = new Database(dbPath);
// Enable foreign keys
db.pragma('foreign_keys = ON');
// Create orders table
const createOrdersTable = () => {
const sql = `
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
stripe_session_id TEXT UNIQUE NOT NULL,
stripe_payment_intent TEXT,
wren_order_id TEXT,
customer_email TEXT,
tons REAL NOT NULL,
portfolio_id INTEGER NOT NULL,
base_amount INTEGER NOT NULL,
processing_fee INTEGER NOT NULL,
total_amount INTEGER NOT NULL,
currency TEXT DEFAULT 'USD',
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`;
// Using better-sqlite3's prepare/run for safety (not child_process)
db.prepare(sql).run();
console.log('✅ Orders table created successfully');
};
// Create indexes for faster lookups
const createIndexes = () => {
const indexes = [
'CREATE INDEX IF NOT EXISTS idx_stripe_session_id ON orders(stripe_session_id)',
'CREATE INDEX IF NOT EXISTS idx_status ON orders(status)',
'CREATE INDEX IF NOT EXISTS idx_created_at ON orders(created_at)'
];
indexes.forEach(indexSql => db.prepare(indexSql).run());
console.log('✅ Database indexes created successfully');
};
// Initialize database schema
export const initializeDatabase = () => {
try {
createOrdersTable();
createIndexes();
console.log('✅ Database initialized successfully');
return true;
} catch (error) {
console.error('❌ Database initialization failed:', error);
return false;
}
};
// Run initialization if called directly
if (import.meta.url === `file://${process.argv[1]}`) {
initializeDatabase();
db.close();
}
export default db;

17
server/config/stripe.js Normal file
View File

@ -0,0 +1,17 @@
import Stripe from 'stripe';
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error('STRIPE_SECRET_KEY environment variable is required');
}
// Initialize Stripe with secret key
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: '2024-12-18.acacia',
});
// Webhook configuration
export const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
console.log('✅ Stripe client initialized');
export default stripe;

80
server/index.js Normal file
View File

@ -0,0 +1,80 @@
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import { initializeDatabase } from './config/database.js';
import checkoutRoutes from './routes/checkout.js';
import webhookRoutes from './routes/webhooks.js';
const app = express();
const PORT = process.env.PORT || 3001;
// Initialize database
console.log('🗄️ Initializing database...');
initializeDatabase();
// CORS configuration
const corsOptions = {
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
};
app.use(cors(corsOptions));
// IMPORTANT: Webhook routes must come BEFORE express.json() middleware
// because Stripe webhooks require raw body for signature verification
app.use('/api/webhooks', webhookRoutes);
// JSON body parser for all other routes
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/checkout', checkoutRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error('❌ Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Start server
app.listen(PORT, () => {
console.log('');
console.log('🚀 Puffin App Server');
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log(`📡 Server running on port ${PORT}`);
console.log(`🌐 Frontend URL: ${process.env.FRONTEND_URL}`);
console.log(`🔑 Stripe configured: ${!!process.env.STRIPE_SECRET_KEY}`);
console.log(`🌱 Wren API configured: ${!!process.env.WREN_API_TOKEN}`);
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
console.log('');
console.log('📝 Available endpoints:');
console.log(` GET http://localhost:${PORT}/health`);
console.log(` POST http://localhost:${PORT}/api/checkout/create-session`);
console.log(` GET http://localhost:${PORT}/api/checkout/session/:sessionId`);
console.log(` POST http://localhost:${PORT}/api/webhooks/stripe`);
console.log('');
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('👋 SIGTERM received, shutting down gracefully...');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('👋 SIGINT received, shutting down gracefully...');
process.exit(0);
});

166
server/models/Order.js Normal file
View File

@ -0,0 +1,166 @@
import { db } from '../config/database.js';
import { randomUUID } from 'crypto';
export class Order {
/**
* Create a new order in the database
* @param {Object} orderData - Order data
* @param {string} orderData.stripeSessionId - Stripe checkout session ID
* @param {string} orderData.customerEmail - Customer email
* @param {number} orderData.tons - Carbon offset tons
* @param {number} orderData.portfolioId - Portfolio ID
* @param {number} orderData.baseAmount - Base amount in cents
* @param {number} orderData.processingFee - Processing fee in cents
* @param {number} orderData.totalAmount - Total amount in cents
* @param {string} orderData.currency - Currency code (default: USD)
* @returns {Object} Created order
*/
static create({
stripeSessionId,
customerEmail,
tons,
portfolioId,
baseAmount,
processingFee,
totalAmount,
currency = 'USD'
}) {
const id = randomUUID();
const now = new Date().toISOString();
const stmt = db.prepare(`
INSERT INTO orders (
id, stripe_session_id, customer_email, tons, portfolio_id,
base_amount, processing_fee, total_amount, currency,
status, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
`);
stmt.run(
id,
stripeSessionId,
customerEmail,
tons,
portfolioId,
baseAmount,
processingFee,
totalAmount,
currency,
now,
now
);
return this.findById(id);
}
/**
* Find order by ID
* @param {string} id - Order ID
* @returns {Object|null} Order or null
*/
static findById(id) {
const stmt = db.prepare('SELECT * FROM orders WHERE id = ?');
return stmt.get(id);
}
/**
* Find order by Stripe session ID
* @param {string} sessionId - Stripe session ID
* @returns {Object|null} Order or null
*/
static findBySessionId(sessionId) {
const stmt = db.prepare('SELECT * FROM orders WHERE stripe_session_id = ?');
return stmt.get(sessionId);
}
/**
* Update order status
* @param {string} id - Order ID
* @param {string} status - New status (pending, paid, fulfilled, failed)
* @returns {Object} Updated order
*/
static updateStatus(id, status) {
const now = new Date().toISOString();
const stmt = db.prepare('UPDATE orders SET status = ?, updated_at = ? WHERE id = ?');
stmt.run(status, now, id);
return this.findById(id);
}
/**
* Update order with payment intent ID
* @param {string} id - Order ID
* @param {string} paymentIntentId - Stripe payment intent ID
* @returns {Object} Updated order
*/
static updatePaymentIntent(id, paymentIntentId) {
const now = new Date().toISOString();
const stmt = db.prepare('UPDATE orders SET stripe_payment_intent = ?, updated_at = ? WHERE id = ?');
stmt.run(paymentIntentId, now, id);
return this.findById(id);
}
/**
* Update order with Wren order ID after fulfillment
* @param {string} id - Order ID
* @param {string} wrenOrderId - Wren API order ID
* @param {string} status - Order status (fulfilled or failed)
* @returns {Object} Updated order
*/
static updateWrenOrder(id, wrenOrderId, status = 'fulfilled') {
const now = new Date().toISOString();
const stmt = db.prepare('UPDATE orders SET wren_order_id = ?, status = ?, updated_at = ? WHERE id = ?');
stmt.run(wrenOrderId, status, now, id);
return this.findById(id);
}
/**
* Get all orders (with optional filters)
* @param {Object} filters - Filter options
* @param {string} filters.status - Filter by status
* @param {number} filters.limit - Limit results
* @param {number} filters.offset - Offset for pagination
* @returns {Array} Array of orders
*/
static findAll({ status, limit = 100, offset = 0 } = {}) {
let query = 'SELECT * FROM orders';
const params = [];
if (status) {
query += ' WHERE status = ?';
params.push(status);
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
params.push(limit, offset);
const stmt = db.prepare(query);
return stmt.all(...params);
}
/**
* Get order count by status
* @returns {Object} Count by status
*/
static getStatusCounts() {
const stmt = db.prepare('SELECT status, COUNT(*) as count FROM orders GROUP BY status');
const rows = stmt.all();
return rows.reduce((acc, row) => {
acc[row.status] = row.count;
return acc;
}, {});
}
/**
* Delete order (for testing purposes only)
* @param {string} id - Order ID
* @returns {boolean} Success
*/
static delete(id) {
const stmt = db.prepare('DELETE FROM orders WHERE id = ?');
const result = stmt.run(id);
return result.changes > 0;
}
}
export default Order;

1432
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
server/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "puffin-app-server",
"version": "1.0.0",
"description": "Backend server for Puffin App - Stripe checkout and carbon offset processing",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js",
"init-db": "node config/database.js"
},
"keywords": [
"stripe",
"carbon-offset",
"express"
],
"author": "",
"license": "ISC",
"dependencies": {
"stripe": "^17.5.0",
"express": "^4.21.2",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"better-sqlite3": "^11.8.1",
"axios": "^1.6.7"
}
}

177
server/routes/checkout.js Normal file
View File

@ -0,0 +1,177 @@
import express from 'express';
import { stripe } from '../config/stripe.js';
import { Order } from '../models/Order.js';
const router = express.Router();
// Portfolio pricing configuration (price per ton in USD)
const PORTFOLIO_PRICING = {
1: 15, // Balanced portfolio
2: 18, // High-impact portfolio
3: 20 // Premium portfolio
};
// Processing fee percentage
const PROCESSING_FEE_PERCENT = 0.03; // 3%
/**
* Calculate pricing with processing fee
* @param {number} tons - Number of tons
* @param {number} portfolioId - Portfolio ID
* @returns {Object} Pricing breakdown
*/
function calculatePricing(tons, portfolioId) {
const pricePerTon = PORTFOLIO_PRICING[portfolioId] || 18; // Default to $18/ton
const baseAmount = Math.round(tons * pricePerTon * 100); // Convert to cents
const processingFee = Math.round(baseAmount * PROCESSING_FEE_PERCENT);
const totalAmount = baseAmount + processingFee;
return {
baseAmount,
processingFee,
totalAmount,
pricePerTon
};
}
/**
* POST /api/checkout/create-session
* Create a Stripe Checkout Session
*/
router.post('/create-session', async (req, res) => {
try {
const { tons, portfolioId, customerEmail } = req.body;
// Validation
if (!tons || tons <= 0) {
return res.status(400).json({ error: 'Invalid tons value' });
}
if (!portfolioId || ![1, 2, 3].includes(portfolioId)) {
return res.status(400).json({ error: 'Invalid portfolio ID' });
}
// Calculate pricing
const { baseAmount, processingFee, totalAmount, pricePerTon } = calculatePricing(tons, portfolioId);
// Create line items for Stripe
const lineItems = [
{
price_data: {
currency: 'usd',
product_data: {
name: `Carbon Offset - ${tons} tons`,
description: `Portfolio ${portfolioId} at $${pricePerTon}/ton`,
images: ['https://puffin-app.example.com/images/carbon-offset.png'], // Optional: Add your logo
},
unit_amount: baseAmount, // Base amount in cents
},
quantity: 1,
},
{
price_data: {
currency: 'usd',
product_data: {
name: 'Processing Fee (3%)',
description: 'Transaction processing fee',
},
unit_amount: processingFee, // Processing fee in cents
},
quantity: 1,
},
];
// Create Stripe Checkout Session
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment',
success_url: `${process.env.FRONTEND_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/checkout/cancel`,
customer_email: customerEmail,
metadata: {
tons: tons.toString(),
portfolioId: portfolioId.toString(),
baseAmount: baseAmount.toString(),
processingFee: processingFee.toString(),
},
});
// Store order in database
const order = Order.create({
stripeSessionId: session.id,
customerEmail: customerEmail || null,
tons,
portfolioId,
baseAmount,
processingFee,
totalAmount,
currency: 'USD',
});
console.log(`✅ Created checkout session: ${session.id}`);
console.log(` Order ID: ${order.id}`);
console.log(` Amount: $${(totalAmount / 100).toFixed(2)} (${tons} tons @ $${pricePerTon}/ton + 3% fee)`);
res.json({
sessionId: session.id,
url: session.url,
orderId: order.id,
});
} catch (error) {
console.error('❌ Checkout session creation error:', error);
res.status(500).json({
error: 'Failed to create checkout session',
message: error.message,
});
}
});
/**
* GET /api/checkout/session/:sessionId
* Retrieve checkout session details
*/
router.get('/session/:sessionId', async (req, res) => {
try {
const { sessionId } = req.params;
// Get order from database
const order = Order.findBySessionId(sessionId);
if (!order) {
return res.status(404).json({ error: 'Order not found' });
}
// Get Stripe session (optional - for additional details)
const session = await stripe.checkout.sessions.retrieve(sessionId);
res.json({
order: {
id: order.id,
tons: order.tons,
portfolioId: order.portfolio_id,
baseAmount: order.base_amount,
processingFee: order.processing_fee,
totalAmount: order.total_amount,
currency: order.currency,
status: order.status,
wrenOrderId: order.wren_order_id,
createdAt: order.created_at,
},
session: {
paymentStatus: session.payment_status,
customerEmail: session.customer_details?.email,
},
});
} catch (error) {
console.error('❌ Session retrieval error:', error);
res.status(500).json({
error: 'Failed to retrieve session',
message: error.message,
});
}
});
export default router;

176
server/routes/webhooks.js Normal file
View File

@ -0,0 +1,176 @@
import express from 'express';
import { stripe, webhookSecret } from '../config/stripe.js';
import { Order } from '../models/Order.js';
import { createWrenOffsetOrder } from '../utils/wrenClient.js';
const router = express.Router();
/**
* POST /api/webhooks/stripe
* Handle Stripe webhook events
*
* IMPORTANT: This endpoint requires raw body, not JSON parsed
*/
router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
// Verify webhook signature
event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
} catch (err) {
console.error('❌ Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
console.log(`📬 Received webhook: ${event.type}`);
// Handle different event types
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutSessionCompleted(event.data.object);
break;
case 'checkout.session.async_payment_succeeded':
await handleAsyncPaymentSucceeded(event.data.object);
break;
case 'checkout.session.async_payment_failed':
await handleAsyncPaymentFailed(event.data.object);
break;
default:
console.log(` Unhandled event type: ${event.type}`);
}
// Return 200 to acknowledge receipt
res.json({ received: true });
});
/**
* Handle checkout.session.completed event
* This fires when payment succeeds (or session completes for delayed payment methods)
*/
async function handleCheckoutSessionCompleted(session) {
console.log(`✅ Checkout session completed: ${session.id}`);
try {
// Find order in database
const order = Order.findBySessionId(session.id);
if (!order) {
console.error(`❌ Order not found for session: ${session.id}`);
return;
}
// Update order with payment intent ID
if (session.payment_intent) {
Order.updatePaymentIntent(order.id, session.payment_intent);
}
// Update status to paid
Order.updateStatus(order.id, 'paid');
console.log(`💳 Payment confirmed for order: ${order.id}`);
console.log(` Customer: ${session.customer_details?.email}`);
console.log(` Amount: $${(order.total_amount / 100).toFixed(2)}`);
// Fulfill order via Wren API
await fulfillOrder(order, session);
} catch (error) {
console.error('❌ Error handling checkout session completed:', error);
}
}
/**
* Handle async payment succeeded (e.g., ACH, bank transfer)
*/
async function handleAsyncPaymentSucceeded(session) {
console.log(`✅ Async payment succeeded: ${session.id}`);
try {
const order = Order.findBySessionId(session.id);
if (!order) {
console.error(`❌ Order not found for session: ${session.id}`);
return;
}
// Update status to paid
Order.updateStatus(order.id, 'paid');
// Fulfill order
await fulfillOrder(order, session);
} catch (error) {
console.error('❌ Error handling async payment succeeded:', error);
}
}
/**
* Handle async payment failed
*/
async function handleAsyncPaymentFailed(session) {
console.log(`❌ Async payment failed: ${session.id}`);
try {
const order = Order.findBySessionId(session.id);
if (!order) {
console.error(`❌ Order not found for session: ${session.id}`);
return;
}
// Update status to failed
Order.updateStatus(order.id, 'failed');
console.log(`💔 Order ${order.id} marked as failed`);
// TODO: Send notification to customer about failed payment
} catch (error) {
console.error('❌ Error handling async payment failed:', error);
}
}
/**
* Fulfill order by creating carbon offset via Wren API
* @param {Object} order - Database order object
* @param {Object} session - Stripe session object
*/
async function fulfillOrder(order, session) {
try {
console.log(`🌱 Fulfilling order ${order.id} via Wren API...`);
// Create Wren offset order
const wrenOrder = await createWrenOffsetOrder({
tons: order.tons,
portfolioId: order.portfolio_id,
customerEmail: session.customer_details?.email || order.customer_email,
currency: order.currency,
amountCents: order.total_amount,
dryRun: false, // Set to true for testing without creating real offsets
});
// Update order with Wren order ID
Order.updateWrenOrder(order.id, wrenOrder.id, 'fulfilled');
console.log(`✅ Order ${order.id} fulfilled successfully`);
console.log(` Wren Order ID: ${wrenOrder.id}`);
console.log(` Tons offset: ${order.tons}`);
// TODO: Send confirmation email to customer
} catch (error) {
console.error(`❌ Order fulfillment failed for order ${order.id}:`, error);
// Mark order as paid but unfulfilled (manual intervention needed)
Order.updateStatus(order.id, 'paid');
// TODO: Send alert to admin about failed fulfillment
}
}
export default router;

View File

@ -0,0 +1,93 @@
import axios from 'axios';
const WREN_API_BASE_URL = 'https://api.wren.co/v1';
/**
* Create a carbon offset order via Wren Climate API
* @param {Object} orderData - Order data
* @param {number} orderData.tons - Number of tons to offset
* @param {number} orderData.portfolioId - Portfolio ID
* @param {string} orderData.customerEmail - Customer email
* @param {string} orderData.currency - Currency code
* @param {number} orderData.amountCents - Amount in cents
* @param {boolean} orderData.dryRun - Dry run mode (default: false)
* @returns {Promise<Object>} Wren order response
*/
export async function createWrenOffsetOrder({
tons,
portfolioId,
customerEmail,
currency,
amountCents,
dryRun = false
}) {
const apiToken = process.env.WREN_API_TOKEN;
if (!apiToken) {
throw new Error('WREN_API_TOKEN environment variable is required');
}
try {
const response = await axios.post(
`${WREN_API_BASE_URL}/offset_orders`,
{
tons: parseFloat(tons),
portfolio: portfolioId,
currency: currency.toUpperCase(),
amount_charged: amountCents,
dry_run: dryRun,
source: {
name: 'Puffin App',
email: customerEmail
}
},
{
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json'
}
}
);
console.log('✅ Wren offset order created:', response.data.id);
return response.data;
} catch (error) {
console.error('❌ Wren API error:', error.response?.data || error.message);
throw new Error(`Wren API failed: ${error.response?.data?.message || error.message}`);
}
}
/**
* Get Wren offset order details
* @param {string} orderId - Wren order ID
* @returns {Promise<Object>} Wren order details
*/
export async function getWrenOffsetOrder(orderId) {
const apiToken = process.env.WREN_API_TOKEN;
if (!apiToken) {
throw new Error('WREN_API_TOKEN environment variable is required');
}
try {
const response = await axios.get(
`${WREN_API_BASE_URL}/offset_orders/${orderId}`,
{
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json'
}
}
);
return response.data;
} catch (error) {
console.error('❌ Wren API error:', error.response?.data || error.message);
throw new Error(`Wren API failed: ${error.response?.data?.message || error.message}`);
}
}
export default {
createWrenOffsetOrder,
getWrenOffsetOrder
};

View File

@ -9,6 +9,8 @@ import { HowItWorks } from './components/HowItWorks';
import { About } from './components/About';
import { Contact } from './components/Contact';
import { OffsetOrder } from './components/OffsetOrder';
import CheckoutSuccess from './pages/CheckoutSuccess';
import CheckoutCancel from './pages/CheckoutCancel';
import { getVesselData } from './api/aisClient';
import { calculateTripCarbon } from './utils/carbonCalculator';
import { analytics } from './utils/analytics';
@ -34,12 +36,18 @@ function App() {
const [calculatorType, setCalculatorType] = useState<CalculatorType>('trip');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isMobileApp, setIsMobileApp] = useState(false);
const [isCheckoutSuccess, setIsCheckoutSuccess] = useState(false);
const [isCheckoutCancel, setIsCheckoutCancel] = useState(false);
const [showHeader, setShowHeader] = useState(true);
const [lastScrollY, setLastScrollY] = useState(0);
useEffect(() => {
// Check if we're on the mobile app route
// Check if we're on special routes
const path = window.location.pathname;
setIsMobileApp(path === '/mobile-app');
setIsCheckoutSuccess(path === '/checkout/success');
setIsCheckoutCancel(path === '/checkout/cancel');
analytics.pageView(path);
}, [currentPage]);
@ -48,12 +56,37 @@ function App() {
const handlePopState = () => {
const path = window.location.pathname;
setIsMobileApp(path === '/mobile-app');
setIsCheckoutSuccess(path === '/checkout/success');
setIsCheckoutCancel(path === '/checkout/cancel');
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
useEffect(() => {
// Hide header on scroll down, show on scroll up
const handleScroll = () => {
const currentScrollY = window.scrollY;
// Always show header at the top of the page
if (currentScrollY < 10) {
setShowHeader(true);
} else if (currentScrollY > lastScrollY) {
// Scrolling down
setShowHeader(false);
} else {
// Scrolling up
setShowHeader(true);
}
setLastScrollY(currentScrollY);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, [lastScrollY]);
const handleSearch = async (imo: string) => {
setLoading(true);
setError(null);
@ -150,9 +183,27 @@ function App() {
);
}
// If we're on the checkout success route, render only the success page
if (isCheckoutSuccess) {
return <CheckoutSuccess />;
}
// If we're on the checkout cancel route, render only the cancel page
if (isCheckoutCancel) {
return <CheckoutCancel />;
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 wave-pattern">
<header className="glass-nav shadow-luxury relative z-50 sticky top-0">
<header
className="glass-nav shadow-luxury z-50 fixed top-0 left-0 right-0 transition-transform duration-300 ease-in-out"
style={{
transform: showHeader ? 'translate3d(0,0,0)' : 'translate3d(0,-100%,0)',
WebkitTransform: showHeader ? 'translate3d(0,0,0)' : 'translate3d(0,-100%,0)',
WebkitBackfaceVisibility: 'hidden',
backfaceVisibility: 'hidden'
}}
>
<div className="max-w-7xl mx-auto px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<motion.div
@ -259,7 +310,7 @@ function App() {
</div>
</header>
<main className="max-w-[1600px] mx-auto py-8 sm:py-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
<main className="max-w-[1600px] mx-auto pt-24 pb-8 sm:pb-12 px-4 sm:px-6 lg:px-8 overflow-hidden">
<AnimatePresence mode="wait">
<motion.div
key={currentPage + (showOffsetOrder ? '-offset' : '')}

100
src/api/checkoutClient.ts Normal file
View File

@ -0,0 +1,100 @@
import axios from 'axios';
import { logger } from '../utils/logger';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001';
export interface CreateCheckoutSessionParams {
tons: number;
portfolioId: number;
customerEmail?: string;
}
export interface CheckoutSessionResponse {
sessionId: string;
url: string;
orderId: string;
}
export interface OrderDetails {
order: {
id: string;
tons: number;
portfolioId: number;
baseAmount: number;
processingFee: number;
totalAmount: number;
currency: string;
status: string;
wrenOrderId: string | null;
createdAt: string;
};
session: {
paymentStatus: string;
customerEmail?: string;
};
}
/**
* Create a Stripe checkout session
* @param params Checkout session parameters
* @returns Checkout session response with redirect URL
*/
export async function createCheckoutSession(
params: CreateCheckoutSessionParams
): Promise<CheckoutSessionResponse> {
try {
logger.info('Creating checkout session:', params);
const response = await axios.post<CheckoutSessionResponse>(
`${API_BASE_URL}/api/checkout/create-session`,
params
);
logger.info('Checkout session created:', response.data.sessionId);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('Checkout session creation failed:', error.response?.data || error.message);
throw new Error(error.response?.data?.message || 'Failed to create checkout session');
}
throw error;
}
}
/**
* Get order details by session ID
* @param sessionId Stripe session ID
* @returns Order details
*/
export async function getOrderDetails(sessionId: string): Promise<OrderDetails> {
try {
logger.info('Fetching order details for session:', sessionId);
const response = await axios.get<OrderDetails>(
`${API_BASE_URL}/api/checkout/session/${sessionId}`
);
logger.info('Order details retrieved:', response.data.order.id);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error('Order details retrieval failed:', error.response?.data || error.message);
throw new Error(error.response?.data?.message || 'Failed to retrieve order details');
}
throw error;
}
}
/**
* Health check for backend API
* @returns true if backend is healthy
*/
export async function checkBackendHealth(): Promise<boolean> {
try {
const response = await axios.get(`${API_BASE_URL}/health`);
return response.data.status === 'ok';
} catch (error) {
logger.error('Backend health check failed:', error);
return false;
}
}

View File

@ -115,11 +115,11 @@ export async function getPortfolios(): Promise<Portfolio[]> {
}
logger.log('[wrenClient] Getting portfolios with token:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
const api = createApiClient();
// Removed the /api prefix to match the working example
const response = await api.get('/portfolios');
if (!response.data?.portfolios?.length) {
logger.warn('[wrenClient] No portfolios returned from API, using fallback');
return [DEFAULT_PORTFOLIO];
@ -157,6 +157,7 @@ export async function getPortfolios(): Promise<Portfolio[]> {
imageUrl: project.image_url, // Map from snake_case API response
pricePerTon: projectPricePerTon,
percentage: projectPercentage, // Include percentage field
certificationStatus: project.certification_status, // Map certification status from API
// Remove fields that aren't in the API
// The required type fields are still in the type definition for compatibility
// but we no longer populate them with default values

View File

@ -0,0 +1,74 @@
import React from 'react';
import { Shield, ShieldCheck, ShieldAlert, Clock } from 'lucide-react';
import type { CertificationStatus } from '../types';
interface CertificationBadgeProps {
status?: CertificationStatus;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
export function CertificationBadge({ status, size = 'md', className = '' }: CertificationBadgeProps) {
if (!status) return null;
const sizeClasses = {
sm: 'text-xs px-2 py-0.5',
md: 'text-sm px-3 py-1',
lg: 'text-base px-4 py-1.5',
};
const iconSize = {
sm: 12,
md: 14,
lg: 16,
};
const getBadgeConfig = (status: CertificationStatus) => {
switch (status) {
case 'standard 2023':
return {
icon: ShieldCheck,
bgColor: 'bg-emerald-100',
textColor: 'text-emerald-800',
borderColor: 'border-emerald-300',
label: 'Standard 2023',
};
case 'standard 2020':
return {
icon: ShieldCheck,
bgColor: 'bg-blue-100',
textColor: 'text-blue-800',
borderColor: 'border-blue-300',
label: 'Standard 2020',
};
case 'in progress':
return {
icon: Clock,
bgColor: 'bg-yellow-100',
textColor: 'text-yellow-800',
borderColor: 'border-yellow-300',
label: 'In Progress',
};
case 'nonstandard':
return {
icon: ShieldAlert,
bgColor: 'bg-gray-100',
textColor: 'text-gray-700',
borderColor: 'border-gray-300',
label: 'Non-Standard',
};
}
};
const config = getBadgeConfig(status);
const Icon = config.icon;
return (
<span
className={`inline-flex items-center gap-1.5 font-medium rounded-full border ${config.bgColor} ${config.textColor} ${config.borderColor} ${sizeClasses[size]} ${className}`}
>
<Icon size={iconSize[size]} />
<span>{config.label}</span>
</span>
);
}

View File

@ -1,24 +1,34 @@
import React from 'react';
import type { CurrencyCode } from '../types';
import { currencies } from '../utils/currencies';
import { FormSelect } from './forms/FormSelect';
interface Props {
value: CurrencyCode;
onChange: (currency: CurrencyCode) => void;
id?: string;
label?: string;
}
export function CurrencySelect({ value, onChange }: Props) {
export function CurrencySelect({ value, onChange, id = 'currency-select', label = 'Select Currency' }: Props) {
// Get the symbol for the currently selected currency
// Use $ for CHF instead of showing 'CHF' text
const symbol = currencies[value]?.symbol || '$';
const currentSymbol = symbol === 'CHF' ? '$' : symbol;
return (
<select
<FormSelect
id={id}
label={label}
icon={<span className="text-lg font-semibold">{currentSymbol}</span>}
value={value}
onChange={(e) => onChange(e.target.value as CurrencyCode)}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:ring-opacity-50"
>
{Object.entries(currencies).map(([code, currency]) => (
{Object.entries(currencies).map(([code]) => (
<option key={code} value={code}>
{currency.symbol} {code}
{code}
</option>
))}
</select>
</FormSelect>
);
}

View File

@ -113,7 +113,6 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
<MobileOffsetOrder
tons={offsetTons}
monetaryAmount={monetaryAmount}
currency={currency}
onBack={() => setShowOffsetOrder(false)}
/>
);

View File

@ -1,16 +1,19 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Check, ArrowLeft, Loader2, User, Mail, Phone, Globe2, TreePine, Waves, Factory, Wind, X, AlertCircle } from 'lucide-react';
import { Check, ArrowLeft, Loader2, User, Mail, Phone, Globe2, TreePine, Waves, Factory, Wind, X, AlertCircle, Flame, Snowflake, Mountain, Sprout, Package, Droplet, Leaf, Zap } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { createOffsetOrder, getPortfolios } from '../api/wrenClient';
import type { CurrencyCode, OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
import { currencies, formatCurrency, getCurrencyByCode } from '../utils/currencies';
import type { OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
import { currencies, formatCurrency } from '../utils/currencies';
import { config } from '../utils/config';
import { sendFormspreeEmail } from '../utils/email';
import { RadialProgress } from './RadialProgress';
import { PortfolioDonutChart } from './PortfolioDonutChart';
import { getProjectColor } from '../utils/portfolioColors';
import { CertificationBadge } from './CertificationBadge';
interface Props {
tons: number;
monetaryAmount?: number;
currency: CurrencyCode;
onBack: () => void;
}
@ -19,27 +22,42 @@ interface ProjectTypeIconProps {
}
const ProjectTypeIcon = ({ project }: ProjectTypeIconProps) => {
if (!project || !project.type) {
// Safely check if project exists
if (!project || !project.name) {
return <Globe2 className="text-blue-500" size={16} />;
}
const type = project.type.toLowerCase();
switch (type) {
case 'direct air capture':
return <Factory className="text-purple-500" size={16} />;
case 'blue carbon':
return <Waves className="text-blue-500" size={16} />;
case 'renewable energy':
return <Wind className="text-green-500" size={16} />;
case 'forestry':
return <TreePine className="text-green-500" size={16} />;
default:
return <Globe2 className="text-blue-500" size={16} />;
const name = project.name.toLowerCase();
// Match on project name to determine appropriate icon
if (name.includes('rainforest') || name.includes('forest')) {
return <TreePine className="text-green-600" size={16} />;
} else if (name.includes('biochar') && name.includes('carbon removal')) {
return <Flame className="text-orange-600" size={16} />;
} else if (name.includes('refrigerant')) {
return <Snowflake className="text-cyan-600" size={16} />;
} else if (name.includes('rock weathering') || name.includes('enhanced weathering')) {
return <Mountain className="text-amber-700" size={16} />;
} else if (name.includes('rice paddies') || name.includes('paddies')) {
return <Sprout className="text-green-500" size={16} />;
} else if (name.includes('mangrove') || name.includes('blue carbon')) {
return <Waves className="text-blue-600" size={16} />;
} else if (name.includes('biomass storage') || name.includes('underground')) {
return <Package className="text-amber-800" size={16} />;
} else if (name.includes('bio-oil') || name.includes('oil')) {
return <Droplet className="text-blue-700" size={16} />;
} else if (name.includes('adipic') || name.includes('nitrous oxide') || name.includes('factory')) {
return <Factory className="text-gray-600" size={16} />;
} else if (name.includes('renewable') || name.includes('wind') || name.includes('solar')) {
return <Wind className="text-green-500" size={16} />;
} else if (name.includes('direct air capture') || name.includes('dac')) {
return <Zap className="text-purple-600" size={16} />;
} else {
return <Globe2 className="text-blue-500" size={16} />;
}
};
export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Props) {
export function MobileOffsetOrder({ tons, monetaryAmount, onBack }: Props) {
const [currentStep, setCurrentStep] = useState<'summary' | 'projects' | 'confirmation'>('summary');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -47,7 +65,6 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
const [order, setOrder] = useState<OffsetOrderType | null>(null);
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
const [loadingPortfolio, setLoadingPortfolio] = useState(true);
const [selectedProject, setSelectedProject] = useState<OffsetProject | null>(null);
const [offsetPercentage, setOffsetPercentage] = useState(100); // Default to 100%
// Calculate the actual tons to offset based on percentage
@ -129,7 +146,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
const mockOrder: OffsetOrderType = {
id: 'DEMO-' + Date.now().toString().slice(-8),
amountCharged: Math.round(offsetCost * 100), // Convert to cents
currency: currency,
currency: 'USD',
tons: actualOffsetTons,
portfolio: portfolio,
status: 'completed',
@ -146,7 +163,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
const mockOrder: OffsetOrderType = {
id: 'DEMO-' + Date.now().toString().slice(-8),
amountCharged: Math.round(offsetCost * 100),
currency: currency,
currency: 'USD',
tons: actualOffsetTons,
portfolio: portfolio || {
id: 0,
@ -171,8 +188,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
const renderPortfolioPrice = (portfolio: Portfolio) => {
try {
const pricePerTon = portfolio.pricePerTon || 18;
const targetCurrency = getCurrencyByCode(currency);
return formatCurrency(pricePerTon, targetCurrency);
return formatCurrency(pricePerTon, currencies.USD);
} catch (err) {
console.error('Error formatting portfolio price:', err);
return formatCurrency(18, currencies.USD);
@ -180,7 +196,6 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
};
const offsetCost = monetaryAmount || (portfolio ? actualOffsetTons * (portfolio.pricePerTon || 18) : 0);
const targetCurrency = getCurrencyByCode(currency);
const handleInputChange = (field: keyof typeof formData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
@ -190,14 +205,6 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
setCurrentStep('projects');
};
const handleProjectClick = useCallback((project: OffsetProject) => {
setSelectedProject(project);
}, []);
const handleCloseLightbox = () => {
setSelectedProject(null);
};
const renderSummaryStep = () => (
<motion.div
className="space-y-6"
@ -370,7 +377,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
<div className="font-semibold text-blue-900">CO to Offset</div>
<div className="text-sm text-blue-600">From your yacht emissions</div>
</div>
<div className="text-xl font-bold text-blue-900">
<div className="text-xl font-bold text-blue-900 text-right">
{formatTons(actualOffsetTons)} tons
</div>
</div>
@ -380,7 +387,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
<div className="font-semibold text-green-900">Price per Ton</div>
<div className="text-sm text-green-600">Verified carbon credits</div>
</div>
<div className="text-xl font-bold text-green-900">
<div className="text-xl font-bold text-green-900 text-right">
{renderPortfolioPrice(portfolio)}
</div>
</div>
@ -388,8 +395,8 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
<div className="border-t pt-4">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900">Total Cost</span>
<span className="text-2xl font-bold text-gray-900">
{formatCurrency(offsetCost, targetCurrency)}
<span className="text-2xl font-bold text-gray-900 text-right">
{formatCurrency(offsetCost, currencies.USD)}
</span>
</div>
</div>
@ -424,68 +431,84 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
<>
<div className="bg-white rounded-2xl p-6 shadow-sm">
<h3 className="text-lg font-semibold mb-2">{portfolio.name}</h3>
<p className="text-sm text-gray-600 mb-4">
<p className="text-sm text-gray-600">
Your offset will be distributed across these verified carbon reduction projects
</p>
{portfolio.projects && portfolio.projects.length > 0 && (
<div className="space-y-4">
{portfolio.projects.map((project, index) => (
<motion.div
key={project.id || `project-${index}`}
className="border border-gray-200 rounded-xl overflow-hidden cursor-pointer hover:border-blue-400 transition-colors"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
onClick={() => handleProjectClick(project)}
>
{project.imageUrl && (
<div className="relative h-32">
<img
src={project.imageUrl}
alt={project.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-2 left-3 right-3">
<h4 className="text-white font-semibold text-sm">{project.name}</h4>
</div>
</div>
)}
<div className="p-4">
{!project.imageUrl && (
<h4 className="font-semibold text-gray-900 mb-2">{project.name}</h4>
)}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<ProjectTypeIcon project={project} />
<span className="text-xs font-medium text-gray-600">
{project.type || 'Environmental Project'}
</span>
</div>
{project.percentage && (
<span className="text-sm font-semibold text-gray-900">
{(project.percentage * 100).toFixed(0)}%
</span>
)}
</div>
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
{project.shortDescription || project.description}
</p>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500">Price per ton</span>
<span className="font-medium text-gray-900">${project.pricePerTon.toFixed(2)}</span>
</div>
{/* Portfolio Allocation Visualization */}
{portfolio.projects && portfolio.projects.length > 0 && (
<motion.div
className="bg-white rounded-2xl p-6 shadow-sm"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<h4 className="text-lg font-semibold text-gray-900 mb-6 text-center">Portfolio Distribution</h4>
<PortfolioDonutChart
projects={portfolio.projects}
totalTons={actualOffsetTons}
/>
</motion.div>
)}
{/* Project Cards */}
{portfolio.projects && portfolio.projects.length > 0 && (
<div className="bg-white rounded-2xl p-6 shadow-sm space-y-6">
{portfolio.projects.map((project, index) => (
<motion.div
key={project.id || `project-${index}`}
className="pb-6 border-b border-gray-200 last:border-b-0 last:pb-0"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
{project.imageUrl && (
<div className="relative h-32 -mx-6 mb-4 rounded-lg overflow-hidden">
<img
src={project.imageUrl}
alt={project.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
<div className="absolute bottom-2 left-3 right-3">
<h4 className="text-white font-semibold text-sm">{project.name}</h4>
</div>
</div>
</motion.div>
))}
</div>
)}
</div>
)}
<div>
{!project.imageUrl && (
<h4 className="font-semibold text-gray-900 mb-2">{project.name}</h4>
)}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<ProjectTypeIcon project={project} />
<span className="text-xs font-medium text-gray-600">
{project.type || 'Environmental Project'}
</span>
</div>
{project.percentage && (
<RadialProgress
percentage={project.percentage * 100}
size={48}
color={getProjectColor(index, portfolio.projects.length)}
delay={index * 0.1 + 0.3}
/>
)}
</div>
<p className="text-sm text-gray-600 line-clamp-2 mb-2">
{project.shortDescription || project.description}
</p>
<CertificationBadge status={project.certificationStatus} size="sm" />
</div>
</motion.div>
))}
</div>
)}
<div className="bg-blue-50 rounded-xl p-4">
<div className="flex items-center justify-between mb-2">
@ -495,7 +518,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
<div className="flex items-center justify-between">
<span className="font-medium text-blue-900">Portfolio Price</span>
<span className="font-bold text-lg text-blue-900">
{formatCurrency(offsetCost, targetCurrency)}
{formatCurrency(offsetCost, currencies.USD)}
</span>
</div>
</div>
@ -594,11 +617,8 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50">
{/* Mobile Header */}
<motion.header
<header
className="bg-white/80 backdrop-blur-md shadow-sm sticky top-0 z-50"
initial={{ y: -100 }}
animate={{ y: 0 }}
transition={{ duration: 0.3 }}
>
<div className="flex items-center justify-between p-4">
<motion.button
@ -640,7 +660,7 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
</div>
</div>
)}
</motion.header>
</header>
<div className="p-4 pb-20">
<AnimatePresence mode="wait">
@ -649,102 +669,6 @@ export function MobileOffsetOrder({ tons, monetaryAmount, currency, onBack }: Pr
{currentStep === 'confirmation' && renderConfirmationStep()}
</AnimatePresence>
</div>
{/* Lightbox Modal for Project Details */}
<AnimatePresence>
{selectedProject && (
<motion.div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)' }}
onClick={handleCloseLightbox}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<motion.div
className="relative bg-white rounded-lg shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 20 }}
transition={{
duration: 0.4,
ease: [0.22, 1, 0.36, 1],
scale: { type: "spring", stiffness: 300, damping: 30 }
}}
>
<button
onClick={handleCloseLightbox}
className="absolute top-4 right-4 p-2 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors z-10"
aria-label="Close details"
>
<X size={20} />
</button>
{selectedProject.imageUrl && (
<div className="relative h-48 overflow-hidden rounded-t-lg">
<img
src={selectedProject.imageUrl}
alt={selectedProject.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<div className="absolute bottom-4 left-4 right-4">
<h3 className="text-xl font-bold text-white mb-1">{selectedProject.name}</h3>
</div>
</div>
)}
<div className="p-6">
{!selectedProject.imageUrl && (
<>
<h3 className="text-xl font-bold text-gray-900 mb-2">{selectedProject.name}</h3>
<div className="flex items-center space-x-2 mb-4">
<ProjectTypeIcon project={selectedProject} />
<span className="text-gray-600 text-sm">{selectedProject.type || 'Environmental Project'}</span>
</div>
</>
)}
<p className="text-gray-700 mb-4 text-sm">
{selectedProject.description || selectedProject.shortDescription}
</p>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-gray-50 p-3 rounded-lg">
<p className="text-xs text-gray-600 mb-1">Price per Ton</p>
<p className="text-lg font-bold text-gray-900">${selectedProject.pricePerTon.toFixed(2)}</p>
</div>
{selectedProject.percentage && (
<div className="bg-gray-50 p-3 rounded-lg">
<p className="text-xs text-gray-600 mb-1">Portfolio Allocation</p>
<p className="text-lg font-bold text-gray-900">{(selectedProject.percentage * 100).toFixed(1)}%</p>
</div>
)}
</div>
{(selectedProject.location || selectedProject.verificationStandard) && (
<div className="space-y-2 text-sm">
{selectedProject.location && (
<div className="flex items-center justify-between">
<span className="text-gray-600">Location:</span>
<span className="font-medium text-gray-900">{selectedProject.location}</span>
</div>
)}
{selectedProject.verificationStandard && (
<div className="flex items-center justify-between">
<span className="text-gray-600">Verification:</span>
<span className="font-medium text-gray-900">{selectedProject.verificationStandard}</span>
</div>
)}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -1,12 +1,19 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Check, AlertCircle, ArrowLeft, Loader2, Globe2, TreePine, Waves, Factory, Wind, X } from 'lucide-react';
import { Check, AlertCircle, ArrowLeft, Loader2, Globe2, TreePine, Waves, Factory, Wind, X, User, Mail, Phone, Building, Flame, Snowflake, Mountain, Sprout, Package, Droplet, Leaf, Zap } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { createOffsetOrder, getPortfolios } from '../api/wrenClient';
import type { CurrencyCode, OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
import { currencies, formatCurrency, getCurrencyByCode } from '../utils/currencies';
import { getPortfolios } from '../api/wrenClient';
import { createCheckoutSession } from '../api/checkoutClient';
import type { OffsetOrder as OffsetOrderType, Portfolio, OffsetProject } from '../types';
import { currencies, formatCurrency } from '../utils/currencies';
import { config } from '../utils/config';
import { sendFormspreeEmail } from '../utils/email';
import { logger } from '../utils/logger';
import { FormInput } from './forms/FormInput';
import { FormTextarea } from './forms/FormTextarea';
import { RadialProgress } from './RadialProgress';
import { PortfolioDonutChart } from './PortfolioDonutChart';
import { getProjectColor } from '../utils/portfolioColors';
import { CertificationBadge } from './CertificationBadge';
interface Props {
tons: number;
@ -20,24 +27,38 @@ interface ProjectTypeIconProps {
}
const ProjectTypeIcon = ({ project }: ProjectTypeIconProps) => {
// Safely check if project and type exist
if (!project || !project.type) {
// Safely check if project exists
if (!project || !project.name) {
return <Globe2 className="text-blue-500" />;
}
const type = project.type.toLowerCase();
switch (type) {
case 'direct air capture':
return <Factory className="text-purple-500" />;
case 'blue carbon':
return <Waves className="text-blue-500" />;
case 'renewable energy':
return <Wind className="text-green-500" />;
case 'forestry':
return <TreePine className="text-green-500" />;
default:
return <Globe2 className="text-blue-500" />;
const name = project.name.toLowerCase();
// Match on project name to determine appropriate icon
if (name.includes('rainforest') || name.includes('forest')) {
return <TreePine className="text-green-600" />;
} else if (name.includes('biochar') && name.includes('carbon removal')) {
return <Flame className="text-orange-600" />;
} else if (name.includes('refrigerant')) {
return <Snowflake className="text-cyan-600" />;
} else if (name.includes('rock weathering') || name.includes('enhanced weathering')) {
return <Mountain className="text-amber-700" />;
} else if (name.includes('rice paddies') || name.includes('paddies')) {
return <Sprout className="text-green-500" />;
} else if (name.includes('mangrove') || name.includes('blue carbon')) {
return <Waves className="text-blue-600" />;
} else if (name.includes('biomass storage') || name.includes('underground')) {
return <Package className="text-amber-800" />;
} else if (name.includes('bio-oil') || name.includes('oil')) {
return <Droplet className="text-blue-700" />;
} else if (name.includes('adipic') || name.includes('nitrous oxide') || name.includes('factory')) {
return <Factory className="text-gray-600" />;
} else if (name.includes('renewable') || name.includes('wind') || name.includes('solar')) {
return <Wind className="text-green-500" />;
} else if (name.includes('direct air capture') || name.includes('dac')) {
return <Zap className="text-purple-600" />;
} else {
return <Globe2 className="text-blue-500" />;
}
};
@ -46,10 +67,8 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [order, setOrder] = useState<OffsetOrderType | null>(null);
const [currency, setCurrency] = useState<CurrencyCode>('USD');
const [portfolio, setPortfolio] = useState<Portfolio | null>(null);
const [loadingPortfolio, setLoadingPortfolio] = useState(true);
const [selectedProject, setSelectedProject] = useState<OffsetProject | null>(null);
const [offsetPercentage, setOffsetPercentage] = useState(100); // Default to 100%
// Calculate the actual tons to offset based on percentage
@ -125,12 +144,21 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
setError(null);
try {
const newOrder = await createOffsetOrder(portfolio.id, actualOffsetTons);
setOrder(newOrder);
setSuccess(true);
logger.info('[OffsetOrder] Creating checkout session for', actualOffsetTons, 'tons with portfolio', portfolio.id);
// Create Stripe Checkout Session
const checkoutSession = await createCheckoutSession({
tons: actualOffsetTons,
portfolioId: portfolio.id,
});
logger.info('[OffsetOrder] Checkout session created:', checkoutSession.sessionId);
// Redirect to Stripe Checkout
window.location.href = checkoutSession.url;
} catch (err) {
setError('Failed to create offset order. Please try again.');
} finally {
logger.error('[OffsetOrder] Failed to create checkout session:', err);
setError('Failed to create checkout session. Please try again.');
setLoading(false);
}
};
@ -139,8 +167,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
try {
// Get the price per ton from the portfolio
const pricePerTon = portfolio.pricePerTon || 18; // Default based on Wren Climate Fund average
const targetCurrency = getCurrencyByCode(currency);
return formatCurrency(pricePerTon, targetCurrency);
return formatCurrency(pricePerTon, currencies.USD);
} catch (err) {
console.error('Error formatting portfolio price:', err);
return formatCurrency(18, currencies.USD); // Updated fallback
@ -150,28 +177,6 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
// Calculate offset cost using the portfolio price
const offsetCost = monetaryAmount || (portfolio ? actualOffsetTons * (portfolio.pricePerTon || 18) : 0);
// Robust project click handler with multiple fallbacks
const handleProjectClick = useCallback((project: OffsetProject, e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
logger.log('Opening project details for:', project.name);
setSelectedProject(project);
}, []);
// Additional handler for direct button clicks
const handleProjectButtonClick = useCallback((project: OffsetProject) => {
logger.log('Button click - Opening project details for:', project.name);
setSelectedProject(project);
}, []);
// Simple lightbox close handler
const handleCloseLightbox = () => {
logger.log('Closing lightbox');
setSelectedProject(null);
};
return (
<motion.div
className="bg-white rounded-lg shadow-xl p-4 sm:p-8 max-w-7xl w-full relative mx-auto"
@ -227,63 +232,47 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
setLoading(false);
}
}} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Company
</label>
<input
type="text"
value={formData.company}
onChange={(e) => setFormData(prev => ({ ...prev, company: e.target.value }))}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Message
</label>
<textarea
rows={4}
value={formData.message}
onChange={(e) => setFormData(prev => ({ ...prev, message: e.target.value }))}
className="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<FormInput
id="offset-name"
label="Name *"
icon={<User size={20} />}
type="text"
required
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
/>
<FormInput
id="offset-email"
label="Email *"
icon={<Mail size={20} />}
type="email"
required
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
/>
<FormInput
id="offset-phone"
label="Phone"
icon={<Phone size={20} />}
type="tel"
value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
/>
<FormInput
id="offset-company"
label="Company"
icon={<Building size={20} />}
type="text"
value={formData.company}
onChange={(e) => setFormData(prev => ({ ...prev, company: e.target.value }))}
/>
<FormTextarea
id="offset-message"
label="Message"
rows={4}
value={formData.message}
onChange={(e) => setFormData(prev => ({ ...prev, message: e.target.value }))}
/>
<button
type="submit"
disabled={loading}
@ -362,13 +351,32 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
<h3 className="text-xl font-semibold text-gray-900 mb-4">
{portfolio.name}
</h3>
<p className="text-gray-600 mb-6">
<p className="text-gray-600 mb-0">
{portfolio.description}
</p>
{/* Portfolio Allocation Visualization */}
{portfolio.projects && portfolio.projects.length > 0 && (
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 sm:gap-6 mb-6"
<motion.div
className="bg-white rounded-lg px-6 pt-4 pb-6 shadow-md border border-gray-200 mb-8 mt-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<h4 className="text-xl font-semibold text-gray-900 mb-0 md:mb-0 text-center">Portfolio Distribution</h4>
<div className="mt-2 md:-mt-12">
<PortfolioDonutChart
projects={portfolio.projects}
totalTons={actualOffsetTons}
/>
</div>
</motion.div>
)}
{/* Project Cards */}
{portfolio.projects && portfolio.projects.length > 0 && (
<motion.div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 mb-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
@ -382,7 +390,7 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
transition={{ duration: 0.3, delay: index * 0.1 }}
whileHover={{ scale: 1.02 }}
>
{/* Header with title and percentage - Fixed height for alignment */}
{/* Header with title and radial progress */}
<div className="flex items-start justify-between mb-4 min-h-[60px]">
<div className="flex items-start space-x-3 flex-1 pr-2">
<div className="mt-1">
@ -391,9 +399,12 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
<h4 className="font-bold text-gray-900 text-lg leading-tight">{project.name}</h4>
</div>
{project.percentage && (
<span className="text-sm bg-blue-100 text-blue-800 font-medium px-3 py-1 rounded-full flex-shrink-0">
{(project.percentage * 100).toFixed(1)}%
</span>
<RadialProgress
percentage={project.percentage * 100}
size={56}
color={getProjectColor(index, portfolio.projects.length)}
delay={index * 0.1 + 0.3}
/>
)}
</div>
@ -410,37 +421,12 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
)}
{/* Description - This will grow to push price and button to bottom */}
<p className="text-gray-600 mb-4 leading-relaxed flex-grow">
<p className="text-gray-600 mb-3 leading-relaxed flex-grow">
{project.shortDescription || project.description}
</p>
{/* Bottom section - Always aligned at the bottom */}
<div className="mt-auto">
{/* Price info */}
<div className="bg-gray-50 p-3 rounded-lg mb-4">
<div className="flex justify-between items-center">
<span className="text-gray-600 font-medium">Price per ton:</span>
<span className="text-gray-900 font-bold text-lg">
${project.pricePerTon.toFixed(2)}
</span>
</div>
</div>
<CertificationBadge status={project.certificationStatus} size="sm" />
{/* Click button - Primary call to action */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleProjectButtonClick(project);
}}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors duration-200 flex items-center justify-center space-x-2"
>
<span>View Project Details</span>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</motion.div>
))}
</motion.div>
@ -460,67 +446,69 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
</div>
{/* Offset Percentage Slider */}
<motion.div
className="bg-white border rounded-lg p-6 mb-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.55 }}
>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Choose Your Offset Amount</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-4">
<span className="text-gray-600">Offset Percentage:</span>
<span className="text-2xl font-bold text-blue-600">{offsetPercentage}%</span>
</div>
<div className="relative">
{/* Tick marks - visible notches */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 w-full h-2 pointer-events-none flex justify-between items-center">
{[0, 25, 50, 75, 100].map((tick) => (
<div
key={tick}
className="w-[2px] h-2 rounded-full"
style={{
backgroundColor: tick <= offsetPercentage ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.2)'
}}
></div>
))}
{!monetaryAmount && (
<motion.div
className="bg-white border rounded-lg p-6 mb-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.55 }}
>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Choose Your Offset Amount</h3>
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-4">
<span className="text-gray-600">Offset Percentage:</span>
<span className="text-2xl font-bold text-blue-600">{offsetPercentage}%</span>
</div>
<div className="relative">
{/* Tick marks - visible notches */}
<div className="absolute top-1/2 -translate-y-1/2 left-0 w-full h-2 pointer-events-none flex justify-between items-center">
{[0, 25, 50, 75, 100].map((tick) => (
<div
key={tick}
className="w-[2px] h-2 rounded-full"
style={{
backgroundColor: tick <= offsetPercentage ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.2)'
}}
></div>
))}
</div>
{/* Slider */}
<input
type="range"
min="0"
max="100"
value={offsetPercentage}
onChange={(e) => setOffsetPercentage(Number(e.target.value))}
className="relative z-10 w-full h-3 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${offsetPercentage}%, #e5e7eb ${offsetPercentage}%, #e5e7eb 100%)`
}}
/>
</div>
{/* Percentage labels aligned with tick marks */}
<div className="relative mt-2 h-4">
<span className="text-xs text-gray-500 absolute left-0">0%</span>
<span className="text-xs text-gray-500 absolute left-1/4 -translate-x-1/2">25%</span>
<span className="text-xs text-gray-500 absolute left-1/2 -translate-x-1/2">50%</span>
<span className="text-xs text-gray-500 absolute left-3/4 -translate-x-1/2">75%</span>
<span className="text-xs text-gray-500 absolute right-0">100%</span>
</div>
{/* Slider */}
<input
type="range"
min="0"
max="100"
value={offsetPercentage}
onChange={(e) => setOffsetPercentage(Number(e.target.value))}
className="relative z-10 w-full h-3 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
style={{
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${offsetPercentage}%, #e5e7eb ${offsetPercentage}%, #e5e7eb 100%)`
}}
/>
</div>
{/* Percentage labels aligned with tick marks */}
<div className="relative mt-2 h-4">
<span className="text-xs text-gray-500 absolute left-0">0%</span>
<span className="text-xs text-gray-500 absolute left-1/4 -translate-x-1/2">25%</span>
<span className="text-xs text-gray-500 absolute left-1/2 -translate-x-1/2">50%</span>
<span className="text-xs text-gray-500 absolute left-3/4 -translate-x-1/2">75%</span>
<span className="text-xs text-gray-500 absolute right-0">100%</span>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-gray-700">CO to Offset:</span>
<span className="text-xl font-bold text-blue-900">
{formatTons(actualOffsetTons)} tons
</span>
</div>
<div className="text-sm text-blue-600 mt-1">
{offsetPercentage}% of {formatTons(tons)} tons total emissions
</div>
</div>
</div>
<div className="bg-blue-50 p-4 rounded-lg">
<div className="flex justify-between items-center">
<span className="text-gray-700">CO to Offset:</span>
<span className="text-xl font-bold text-blue-900">
{formatTons(actualOffsetTons)} tons
</span>
</div>
<div className="text-sm text-blue-600 mt-1">
{offsetPercentage}% of {formatTons(tons)} tons total emissions
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
<motion.div
className="bg-gray-50 rounded-lg p-6 mb-6"
@ -532,21 +520,21 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
<div className="space-y-4">
<div className="flex justify-between">
<span className="text-gray-600">Amount to Offset:</span>
<span className="font-medium">{formatTons(actualOffsetTons)} tons CO</span>
<span className="font-medium text-right">{formatTons(actualOffsetTons)} tons CO</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Portfolio Distribution:</span>
<span className="font-medium">Automatically optimized</span>
<span className="font-medium text-right">Automatically optimized</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Cost per Ton:</span>
<span className="font-medium">{renderPortfolioPrice(portfolio)}</span>
<span className="font-medium text-right">{renderPortfolioPrice(portfolio)}</span>
</div>
<div className="border-t pt-4">
<div className="flex justify-between">
<span className="text-gray-900 font-semibold">Total Cost:</span>
<span className="text-gray-900 font-semibold">
{formatCurrency(offsetCost, getCurrencyByCode(portfolio.currency))}
<span className="text-gray-900 font-semibold text-right">
{formatCurrency(offsetCost, currencies.USD)}
</span>
</div>
</div>
@ -568,126 +556,14 @@ export function OffsetOrder({ tons, monetaryAmount, onBack, calculatorType }: Pr
{loading ? (
<div className="flex items-center justify-center">
<Loader2 className="animate-spin mr-2" size={20} />
Processing...
Redirecting to checkout...
</div>
) : (
'Confirm Offset Order'
'Proceed to Checkout'
)}
</motion.button>
</>
) : null}
{/* Animated Lightbox Modal */}
<AnimatePresence>
{selectedProject && (
<motion.div
className="fixed inset-0 z-[9999] flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)' }}
onClick={handleCloseLightbox}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
>
<motion.div
className="relative bg-white rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 20 }}
transition={{
duration: 0.4,
ease: [0.22, 1, 0.36, 1],
scale: { type: "spring", stiffness: 300, damping: 30 }
}}
>
{/* Close Button */}
<button
onClick={handleCloseLightbox}
className="absolute top-4 right-4 p-2 rounded-full bg-gray-100 hover:bg-gray-200 transition-colors z-10"
aria-label="Close details"
>
<X size={20} />
</button>
{/* Project Image */}
{selectedProject.imageUrl && (
<div className="relative h-64 md:h-80 overflow-hidden rounded-t-lg">
<img
src={selectedProject.imageUrl}
alt={selectedProject.name}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
<div className="absolute bottom-4 left-4 right-4">
<h3 className="text-2xl font-bold text-white mb-2">{selectedProject.name}</h3>
<div className="flex items-center space-x-2">
<ProjectTypeIcon project={selectedProject} />
<span className="text-white/90">{selectedProject.type || 'Environmental Project'}</span>
</div>
</div>
</div>
)}
{/* Project Details */}
<div className="p-6">
{!selectedProject.imageUrl && (
<>
<h3 className="text-2xl font-bold text-gray-900 mb-2">{selectedProject.name}</h3>
<div className="flex items-center space-x-2 mb-4">
<ProjectTypeIcon project={selectedProject} />
<span className="text-gray-600">{selectedProject.type || 'Environmental Project'}</span>
</div>
</>
)}
<p className="text-gray-700 mb-6">
{selectedProject.description || selectedProject.shortDescription}
</p>
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600 mb-1">Price per Ton</p>
<p className="text-xl font-bold text-gray-900">${selectedProject.pricePerTon.toFixed(2)}</p>
</div>
{selectedProject.percentage && (
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-sm text-gray-600 mb-1">Portfolio Allocation</p>
<p className="text-xl font-bold text-gray-900">{(selectedProject.percentage * 100).toFixed(1)}%</p>
</div>
)}
</div>
{(selectedProject.location || selectedProject.verificationStandard) && (
<div className="space-y-3 mb-6">
{selectedProject.location && (
<div className="flex items-center justify-between">
<span className="text-gray-600">Location:</span>
<span className="font-medium text-gray-900">{selectedProject.location}</span>
</div>
)}
{selectedProject.verificationStandard && (
<div className="flex items-center justify-between">
<span className="text-gray-600">Verification Standard:</span>
<span className="font-medium text-gray-900">{selectedProject.verificationStandard}</span>
</div>
)}
</div>
)}
{selectedProject.impactMetrics && selectedProject.impactMetrics.co2Reduced > 0 && (
<div className="bg-blue-50 p-4 rounded-lg">
<p className="text-sm text-blue-700 mb-1">Impact Metrics</p>
<p className="text-lg font-semibold text-blue-900">
{selectedProject.impactMetrics.co2Reduced.toLocaleString()} tons CO reduced
</p>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}

View File

@ -0,0 +1,346 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import type { OffsetProject } from '../types';
import { getProjectColor, hexToRgba } from '../utils/portfolioColors';
interface PortfolioDonutChartProps {
projects: OffsetProject[];
totalTons: number;
size?: number;
className?: string;
}
interface SegmentPath {
d: string;
percentage: number;
project: OffsetProject;
color: string;
index: number;
}
export function PortfolioDonutChart({
projects,
totalTons,
size = 240,
className = '',
}: PortfolioDonutChartProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
const [containerWidth, setContainerWidth] = useState<number>(0);
const [clickedIndex, setClickedIndex] = useState<number | null>(null);
const centerX = size / 2;
const centerY = size / 2;
const radius = size * 0.35;
const innerRadius = size * 0.25;
// Generate SVG path for a donut segment
const createSegmentPath = (
startAngle: number,
endAngle: number,
outerR: number,
innerR: number
): string => {
const start = polarToCartesian(centerX, centerY, outerR, endAngle);
const end = polarToCartesian(centerX, centerY, outerR, startAngle);
const innerStart = polarToCartesian(centerX, centerY, innerR, endAngle);
const innerEnd = polarToCartesian(centerX, centerY, innerR, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1';
return [
'M', start.x, start.y,
'A', outerR, outerR, 0, largeArcFlag, 0, end.x, end.y,
'L', innerEnd.x, innerEnd.y,
'A', innerR, innerR, 0, largeArcFlag, 1, innerStart.x, innerStart.y,
'Z',
].join(' ');
};
const polarToCartesian = (centerX: number, centerY: number, radius: number, angleInDegrees: number) => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0;
return {
x: centerX + radius * Math.cos(angleInRadians),
y: centerY + radius * Math.sin(angleInRadians),
};
};
// Calculate segments
const segments: SegmentPath[] = [];
let currentAngle = 0;
projects.forEach((project, index) => {
if (!project.percentage) return;
const percentage = project.percentage * 100;
const segmentAngle = (percentage / 100) * 360;
const color = getProjectColor(index, projects.length);
segments.push({
d: createSegmentPath(currentAngle, currentAngle + segmentAngle, radius, innerRadius),
percentage,
project,
color,
index,
});
currentAngle += segmentAngle;
});
const handleSegmentMouseEnter = (index: number, e: React.MouseEvent) => {
setHoveredIndex(index);
const container = e.currentTarget.closest('.donut-container');
if (container) {
const rect = container.getBoundingClientRect();
setContainerWidth(rect.width);
setTooltipPosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
const handleSegmentMouseMove = (e: React.MouseEvent) => {
const container = e.currentTarget.closest('.donut-container');
if (container && hoveredIndex !== null) {
const rect = container.getBoundingClientRect();
setContainerWidth(rect.width);
setTooltipPosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
const handleSegmentMouseLeave = () => {
// Don't clear if segment is clicked (for mobile)
if (clickedIndex === null) {
setHoveredIndex(null);
setTooltipPosition(null);
}
};
const handleSegmentClick = (index: number, e: React.MouseEvent) => {
// Only for desktop - mobile uses legend clicks
e.preventDefault();
e.stopPropagation();
// Toggle clicked state
if (clickedIndex === index) {
setClickedIndex(null);
setHoveredIndex(null);
setTooltipPosition(null);
} else {
setClickedIndex(index);
setHoveredIndex(index);
const container = e.currentTarget.closest('.donut-container');
if (container) {
const rect = container.getBoundingClientRect();
setContainerWidth(rect.width);
setTooltipPosition({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
}
};
const handleLegendClick = (index: number) => {
// Toggle off if clicking the same item, otherwise switch to new item
if (hoveredIndex === index) {
setHoveredIndex(null);
} else {
setHoveredIndex(index);
}
};
const handleLegendMouseEnter = (index: number) => {
// Only respond to hover on desktop
if (clickedIndex === null && window.innerWidth >= 768) {
setHoveredIndex(index);
}
};
const handleLegendMouseLeave = () => {
// Only clear hover on desktop
if (clickedIndex === null && window.innerWidth >= 768) {
setHoveredIndex(null);
}
};
return (
<div className={`flex flex-col items-center ${className}`}>
{/* Donut Chart */}
<div
className="relative w-full max-w-sm md:max-w-lg lg:max-w-xl mx-auto donut-container"
style={{ aspectRatio: '1/1' }}
onClick={(e) => {
// Clear clicked state when clicking on the container (but not on a segment)
if (e.target === e.currentTarget) {
setClickedIndex(null);
setHoveredIndex(null);
setTooltipPosition(null);
}
}}
>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${size} ${size}`}
className="transform rotate-0"
preserveAspectRatio="xMidYMid meet"
>
{segments.map((segment, index) => (
<motion.path
key={segment.project.id || segment.index}
d={segment.d}
fill={segment.color}
initial={{ opacity: 0 }}
animate={{
opacity: hoveredIndex === null || hoveredIndex === segment.index ? 1 : 0.4,
}}
transition={{
duration: 0.15,
delay: segment.index * 0.02,
ease: [0.22, 1, 0.36, 1],
}}
onMouseEnter={(e) => handleSegmentMouseEnter(segment.index, e)}
onMouseMove={handleSegmentMouseMove}
onMouseLeave={handleSegmentMouseLeave}
className="md:cursor-pointer transition-all"
style={{
transformOrigin: `${centerX}px ${centerY}px`,
filter: hoveredIndex === segment.index ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.2))' : 'none',
}}
/>
))}
</svg>
{/* Floating Tooltip - Desktop only */}
<div className="hidden md:block">
<AnimatePresence>
{hoveredIndex !== null && tooltipPosition && segments[hoveredIndex] && (() => {
// Position tooltip ABOVE cursor to avoid overlap consistently
const cursorClearance = 60; // Space above cursor for tooltip
const estimatedTooltipWidth = 250;
const padding = 16;
// Always center tooltip on cursor horizontally
let finalLeftPos = tooltipPosition.x;
const transformX = '-50%'; // Always center
// Calculate where tooltip edges would be with centering
const tooltipLeftEdge = finalLeftPos - estimatedTooltipWidth / 2;
const tooltipRightEdge = finalLeftPos + estimatedTooltipWidth / 2;
// Smoothly constrain position to stay within viewport
if (tooltipLeftEdge < padding) {
// Shift right just enough to fit
finalLeftPos = padding + estimatedTooltipWidth / 2;
} else if (tooltipRightEdge > containerWidth - padding) {
// Shift left just enough to fit
finalLeftPos = containerWidth - padding - estimatedTooltipWidth / 2;
}
return (
<motion.div
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
transition={{ duration: 0.1 }}
className="absolute pointer-events-none z-50"
style={{
left: `${finalLeftPos}px`,
top: `${tooltipPosition.y - cursorClearance}px`,
transform: `translate(${transformX}, -100%)`,
maxWidth: `${containerWidth - padding * 2}px`,
}}
>
<div className="bg-gray-900 text-white px-3 py-2 rounded-lg shadow-xl">
<div className="font-semibold text-sm whitespace-nowrap overflow-hidden text-ellipsis">
{segments[hoveredIndex].project.name}
</div>
<div className="text-xs text-gray-300 mt-0.5 whitespace-nowrap">
{segments[hoveredIndex].percentage.toFixed(1)}% · {(totalTons * segments[hoveredIndex].project.percentage!).toFixed(2)} tons
</div>
</div>
</motion.div>
);
})()}
</AnimatePresence>
</div>
{/* Center content */}
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
<AnimatePresence mode="wait">
{hoveredIndex !== null ? (
<motion.div
key="hovered"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.1 }}
className="text-center px-4"
>
<div className="hidden md:block text-xs text-gray-600 mb-1 break-words text-center leading-tight max-w-[180px]">
{segments[hoveredIndex].project.name}
</div>
<div className="text-2xl font-bold text-gray-900">
{segments[hoveredIndex].percentage.toFixed(1)}%
</div>
<div className="text-xs text-gray-500">
{(totalTons * segments[hoveredIndex].project.percentage!).toFixed(2)} tons
</div>
</motion.div>
) : (
<motion.div
key="default"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.1 }}
className="text-center"
>
<div className="text-xs text-gray-600 mb-1">Total CO</div>
<div className="text-2xl font-bold text-gray-900">{totalTons.toFixed(2)}</div>
<div className="text-xs text-gray-500">tons</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Legend */}
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 w-full">
{segments.map((segment) => (
<motion.div
key={segment.project.id || segment.index}
className="flex items-center gap-2 cursor-pointer p-2 rounded-lg transition-colors"
onClick={() => handleLegendClick(segment.index)}
onMouseEnter={() => handleLegendMouseEnter(segment.index)}
onMouseLeave={handleLegendMouseLeave}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: segment.index * 0.02, duration: 0.2 }}
style={{
backgroundColor: hoveredIndex === segment.index ? hexToRgba(segment.color, 0.1) : 'transparent',
}}
>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: segment.color }}
/>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-900 leading-tight break-words">
{segment.project.name}
</div>
<div className="text-xs text-gray-500">
{segment.percentage.toFixed(1)}%
</div>
</div>
</motion.div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,88 @@
import React from 'react';
import { motion } from 'framer-motion';
import { hexToRgba } from '../utils/portfolioColors';
interface RadialProgressProps {
percentage: number;
size?: number;
color: string;
strokeWidth?: number;
showLabel?: boolean;
className?: string;
delay?: number;
}
export function RadialProgress({
percentage,
size = 56,
color,
strokeWidth = 4,
showLabel = true,
className = '',
delay = 0,
}: RadialProgressProps) {
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const strokeDashoffset = circumference - (percentage / 100) * circumference;
return (
<div
className={`relative ${className}`}
style={{ width: size, height: size }}
>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={hexToRgba(color, 0.15)}
strokeWidth={strokeWidth}
/>
{/* Animated progress circle */}
<motion.circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset }}
transition={{
duration: 1,
delay,
ease: [0.22, 1, 0.36, 1],
}}
/>
</svg>
{/* Percentage label */}
{showLabel && (
<motion.div
className="absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.4,
delay: delay + 0.5,
ease: [0.22, 1, 0.36, 1],
}}
>
<span className="text-sm font-bold text-gray-900">
{percentage.toFixed(1)}%
</span>
</motion.div>
)}
</div>
);
}

View File

@ -1,10 +1,9 @@
import React, { useState, useCallback } from 'react';
import { Leaf } from 'lucide-react';
import { Leaf, Droplet } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import type { VesselData, TripEstimate, CurrencyCode } from '../types';
import type { VesselData, TripEstimate } from '../types';
import { calculateTripCarbon, calculateCarbonFromFuel } from '../utils/carbonCalculator';
import { currencies, formatCurrency } from '../utils/currencies';
import { CurrencySelect } from './CurrencySelect';
import { FormSelect } from './forms/FormSelect';
interface Props {
vesselData: VesselData;
@ -56,7 +55,6 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
};
const [fuelUnit, setFuelUnit] = useState<'liters' | 'gallons'>('liters');
const [tripEstimate, setTripEstimate] = useState<TripEstimate | null>(null);
const [currency, setCurrency] = useState<CurrencyCode>('USD');
const [customAmount, setCustomAmount] = useState<string>('');
const handleCalculate = useCallback((e: React.FormEvent) => {
@ -192,30 +190,21 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
<AnimatePresence mode="wait">
{calculationType === 'custom' ? (
<motion.div
<motion.div
key="custom"
className="space-y-4"
className="space-y-4 overflow-hidden"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Currency
</label>
<div className="max-w-xs">
<CurrencySelect value={currency} onChange={setCurrency} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Enter Amount to Offset
</label>
<div className="relative rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span className="text-gray-500 sm:text-sm">{currencies[currency].symbol}</span>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-4">
<span className="text-gray-500 text-sm font-semibold">$</span>
</div>
<input
type="number"
@ -223,17 +212,17 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
onChange={handleCustomAmountChange}
placeholder="Enter amount"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 pl-7 pr-12 focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
className="block w-full pl-10 pr-16 py-2.5 rounded-lg border border-gray-200 shadow-sm transition-all duration-200 text-sm text-gray-900 font-medium outline-none hover:border-gray-300 hover:shadow-md focus:border-blue-500 focus:ring-2 focus:ring-blue-100 focus:shadow-md"
required
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
<span className="text-gray-500 sm:text-sm">{currency}</span>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-4">
<span className="text-gray-500 text-sm font-medium">USD</span>
</div>
</div>
</div>
{customAmount && Number(customAmount) > 0 && (
<motion.button
<motion.button
onClick={() => onOffsetClick?.(0, Number(customAmount))}
className="w-full bg-blue-500 text-white py-2 px-4 rounded-lg hover:bg-blue-600 transition-colors mt-6"
whileHover={{ scale: 1.03 }}
@ -244,119 +233,109 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
)}
</motion.div>
) : (
<motion.form
<motion.form
key="calculator"
onSubmit={handleCalculate}
className="space-y-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
onSubmit={handleCalculate}
className="space-y-4 overflow-hidden"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
{calculationType === 'fuel' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<label className="block text-sm font-medium text-gray-700">
Fuel Consumption
</label>
<div className="flex space-x-4">
<div className="flex-1">
<AnimatePresence mode="wait">
{calculationType === 'fuel' && (
<motion.div
key="fuel"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="grid grid-cols-2 gap-4 overflow-hidden"
>
<div className="col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Fuel Amount
</label>
<input
type="text"
value={fuelAmount}
onChange={handleFuelAmountChange}
placeholder="Enter amount"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
placeholder="Enter fuel amount"
className="block w-full px-4 py-2.5 rounded-lg border border-gray-200 shadow-sm transition-all duration-200 text-sm text-gray-900 font-medium outline-none hover:border-gray-300 hover:shadow-md focus:border-blue-500 focus:ring-2 focus:ring-blue-100 focus:shadow-md"
required
/>
</div>
<div>
<select
<div className="col-span-2">
<FormSelect
id="fuel-unit"
label="Unit"
icon={<Droplet size={20} />}
value={fuelUnit}
onChange={(e) => setFuelUnit(e.target.value as 'liters' | 'gallons')}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
>
<option value="liters">Liters</option>
<option value="gallons">Gallons</option>
</select>
</FormSelect>
</div>
</div>
</motion.div>
)}
{calculationType === 'distance' && (
<>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<label className="block text-sm font-medium text-gray-700">
Distance (nautical miles)
</label>
<input
type="text"
value={distance}
onChange={handleDistanceChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
</motion.div>
)}
{calculationType === 'distance' && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.2 }}
key="distance"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
className="space-y-4 overflow-hidden"
>
<label className="block text-sm font-medium text-gray-700">
Average Speed (knots)
</label>
<input
type="text"
value={speed}
onChange={handleSpeedChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
</motion.div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Distance (nautical miles)
</label>
<input
type="text"
value={distance}
onChange={handleDistanceChange}
placeholder="Enter distance"
className="block w-full px-4 py-2.5 rounded-lg border border-gray-200 shadow-sm transition-all duration-200 text-sm text-gray-900 font-medium outline-none hover:border-gray-300 hover:shadow-md focus:border-blue-500 focus:ring-2 focus:ring-blue-100 focus:shadow-md"
required
/>
</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.3 }}
>
<label className="block text-sm font-medium text-gray-700">
Fuel Consumption Rate (liters per hour)
</label>
<input
type="text"
value={fuelRate}
onChange={handleFuelRateChange}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200"
required
/>
<p className="mt-1 text-sm text-gray-500">
Typical range: 50 - 500 liters per hour for most yachts
</p>
</motion.div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Average Speed (knots)
</label>
<input
type="text"
value={speed}
onChange={handleSpeedChange}
placeholder="Enter speed"
className="block w-full px-4 py-2.5 rounded-lg border border-gray-200 shadow-sm transition-all duration-200 text-sm text-gray-900 font-medium outline-none hover:border-gray-300 hover:shadow-md focus:border-blue-500 focus:ring-2 focus:ring-blue-100 focus:shadow-md"
required
/>
</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.4 }}
>
<label className="block text-sm font-medium text-gray-700">
Select Currency
</label>
<div className="max-w-xs">
<CurrencySelect value={currency} onChange={setCurrency} />
</div>
</motion.div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Fuel Consumption Rate (liters per hour)
</label>
<input
type="text"
value={fuelRate}
onChange={handleFuelRateChange}
placeholder="Enter fuel rate"
className="block w-full px-4 py-2.5 rounded-lg border border-gray-200 shadow-sm transition-all duration-200 text-sm text-gray-900 font-medium outline-none hover:border-gray-300 hover:shadow-md focus:border-blue-500 focus:ring-2 focus:ring-blue-100 focus:shadow-md"
required
/>
<p className="mt-2 text-sm text-gray-500">
Typical range: 50 - 500 liters per hour for most yachts
</p>
</div>
</motion.div>
)}
</AnimatePresence>
<motion.button
type="submit"
@ -365,12 +344,12 @@ export function TripCalculator({ vesselData, onOffsetClick }: Props) {
whileTap={{ scale: 0.97 }}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: 0.5,
type: "spring",
stiffness: 400,
damping: 17
transition={{
duration: 0.3,
delay: 0.4,
type: "spring",
stiffness: 400,
damping: 17
}}
>
Calculate Impact

View File

@ -0,0 +1,99 @@
import React, { ReactNode } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { AlertCircle } from 'lucide-react';
interface FormFieldWrapperProps {
id: string;
label: string;
icon?: ReactNode;
error?: string;
isFilled: boolean;
isFocused: boolean;
disabled?: boolean;
children: ReactNode;
}
export function FormFieldWrapper({
id,
label,
icon,
error,
isFilled,
isFocused,
disabled,
children,
}: FormFieldWrapperProps) {
const isFloated = isFocused || isFilled;
const hasError = !!error;
return (
<div className="w-full">
<div className="relative">
{/* Icon (if provided) */}
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-puffin-gray-label z-10">
{icon}
</div>
)}
{/* Floating Label */}
<motion.label
htmlFor={id}
className={`
absolute left-3 pointer-events-none origin-left z-10
transition-colors duration-200
${icon ? 'pl-7' : ''}
${hasError ? 'text-puffin-error' : isFloated ? 'text-puffin-blue-focus' : 'text-puffin-gray-label'}
${disabled ? 'opacity-50' : ''}
`}
initial={false}
animate={{
y: isFloated ? -24 : 12,
scale: isFloated ? 0.85 : 1,
}}
transition={{
type: 'tween',
ease: 'easeOut',
duration: 0.2,
}}
>
{label}
</motion.label>
{/* Input Container with Border & Focus Effect */}
<div
className={`
relative rounded-lg transition-all duration-200
${hasError
? isFocused
? 'shadow-focus-red'
: ''
: isFocused
? 'shadow-focus-blue'
: ''
}
`}
>
{children}
</div>
</div>
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
id={`${id}-error`}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="flex items-center gap-1.5 mt-1.5 text-sm text-puffin-error"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,64 @@
import React, { InputHTMLAttributes, ReactNode, useState } from 'react';
import { FormFieldWrapper } from './FormFieldWrapper';
interface FormInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
id: string;
label: string;
icon?: ReactNode;
error?: string;
}
export function FormInput({
id,
label,
icon,
error,
value,
disabled,
...rest
}: FormInputProps) {
const [isFocused, setIsFocused] = useState(false);
const isFilled = !!value;
const hasError = !!error;
return (
<FormFieldWrapper
id={id}
label={label}
icon={icon}
error={error}
isFilled={isFilled}
isFocused={isFocused}
disabled={disabled}
>
<input
id={id}
value={value}
disabled={disabled}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
aria-describedby={hasError ? `${id}-error` : undefined}
aria-invalid={hasError}
className={`
w-full px-3 py-3 pt-5 rounded-lg
border-2 transition-colors duration-200
bg-white
${icon ? 'pl-10' : ''}
${hasError
? 'border-puffin-error text-puffin-error placeholder-puffin-error/50'
: isFocused
? 'border-puffin-blue-focus'
: 'border-puffin-gray-border hover:border-gray-300'
}
${disabled
? 'bg-puffin-gray-disabled cursor-not-allowed opacity-50'
: ''
}
focus:outline-none
text-gray-900 placeholder-transparent
`}
{...rest}
/>
</FormFieldWrapper>
);
}

View File

@ -0,0 +1,115 @@
import React, { SelectHTMLAttributes, ReactNode, useState } from 'react';
import { ChevronDown, AlertCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
interface FormSelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'className'> {
id: string;
label: string;
icon?: ReactNode;
error?: string;
children: ReactNode;
}
export function FormSelect({
id,
label,
icon,
error,
value,
disabled,
children,
...rest
}: FormSelectProps) {
const [isFocused, setIsFocused] = useState(false);
const hasError = !!error;
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
// Blur the select to close it and reset the chevron
e.currentTarget.blur();
setIsFocused(false);
if (rest.onChange) {
rest.onChange(e);
}
};
return (
<div className="w-full">
{/* Label above */}
<label
htmlFor={id}
className={`block text-sm font-medium mb-2 transition-colors duration-200 ${
hasError ? 'text-puffin-error' : 'text-gray-700'
} ${disabled ? 'opacity-50' : ''}`}
>
{label}
</label>
<div className="relative group">
{/* Icon (if provided) */}
{icon && (
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-500 z-10 transition-colors duration-200 group-hover:text-gray-700">
{icon}
</div>
)}
{/* Select element */}
<select
id={id}
value={value}
disabled={disabled}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={handleChange}
aria-describedby={hasError ? `${id}-error` : undefined}
aria-invalid={hasError}
className={`
w-full px-3 py-2.5 pr-10 rounded-lg
border transition-all duration-200
bg-white appearance-none cursor-pointer
text-sm text-gray-900 font-medium outline-none
shadow-sm
${icon ? 'pl-10' : ''}
${hasError
? 'border-red-300 ring-2 ring-red-100 hover:border-red-400'
: isFocused
? 'border-blue-500 ring-2 ring-blue-100 shadow-md'
: 'border-gray-200 hover:border-gray-300 hover:shadow-md'
}
${disabled ? 'bg-gray-50 cursor-not-allowed opacity-60' : ''}
`}
{...rest}
>
{children}
</select>
{/* Chevron Icon */}
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<ChevronDown
className={`w-4 h-4 transition-all duration-300 ${
isFocused ? 'rotate-180 text-blue-500' : 'text-gray-400'
} ${hasError ? 'text-red-400' : ''} ${
!isFocused && !hasError ? 'group-hover:text-gray-600' : ''
}`}
/>
</div>
</div>
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
id={`${id}-error`}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="flex items-center gap-1.5 mt-2 text-sm text-red-600"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{error}</span>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,64 @@
import React, { TextareaHTMLAttributes, ReactNode, useState } from 'react';
import { FormFieldWrapper } from './FormFieldWrapper';
interface FormTextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
id: string;
label: string;
icon?: ReactNode;
error?: string;
}
export function FormTextarea({
id,
label,
icon,
error,
value,
disabled,
...rest
}: FormTextareaProps) {
const [isFocused, setIsFocused] = useState(false);
const isFilled = !!value;
const hasError = !!error;
return (
<FormFieldWrapper
id={id}
label={label}
icon={icon}
error={error}
isFilled={isFilled}
isFocused={isFocused}
disabled={disabled}
>
<textarea
id={id}
value={value}
disabled={disabled}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
aria-describedby={hasError ? `${id}-error` : undefined}
aria-invalid={hasError}
className={`
w-full px-3 py-3 pt-5 rounded-lg
border-2 transition-colors duration-200
bg-white resize-vertical
${icon ? 'pl-10' : ''}
${hasError
? 'border-puffin-error text-puffin-error placeholder-puffin-error/50'
: isFocused
? 'border-puffin-blue-focus'
: 'border-puffin-gray-border hover:border-gray-300'
}
${disabled
? 'bg-puffin-gray-disabled cursor-not-allowed opacity-50'
: ''
}
focus:outline-none
text-gray-900 placeholder-transparent
`}
{...rest}
/>
</FormFieldWrapper>
);
}

View File

@ -0,0 +1,124 @@
import { motion } from 'framer-motion';
export default function CheckoutCancel() {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-2xl w-full"
>
{/* Cancel Header */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="text-center mb-8"
>
<div className="text-yellow-500 text-7xl mb-4"></div>
<h1 className="text-4xl md:text-5xl font-bold text-slate-800 mb-2">
Checkout Cancelled
</h1>
<p className="text-xl text-slate-600">
Your payment was not processed
</p>
</motion.div>
{/* Information Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-white rounded-2xl shadow-luxury p-8 mb-6"
>
<h2 className="text-2xl font-bold text-slate-800 mb-4">What happened?</h2>
<p className="text-slate-600 mb-6 leading-relaxed">
You cancelled the checkout process before completing your payment.
No charges have been made to your card.
</p>
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
<p className="text-blue-800 font-medium">
💡 Your climate impact matters
</p>
<p className="text-blue-700 text-sm mt-1">
Every ton of CO offset helps combat climate change. Consider completing
your purchase to make a positive impact on our planet.
</p>
</div>
</motion.div>
{/* Why Offset Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-gradient-to-r from-cyan-500 to-blue-500 rounded-2xl shadow-luxury p-6 text-white mb-6"
>
<h3 className="text-2xl font-bold mb-4">🌊 Why Carbon Offsetting Matters</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<span className="text-cyan-200 text-xl flex-shrink-0"></span>
<p className="text-cyan-50">
<strong>Protect Marine Ecosystems:</strong> Reduce ocean acidification
and protect marine life.
</p>
</div>
<div className="flex items-start gap-3">
<span className="text-cyan-200 text-xl flex-shrink-0"></span>
<p className="text-cyan-50">
<strong>Support Verified Projects:</strong> All projects are certified
and verified for real climate impact.
</p>
</div>
<div className="flex items-start gap-3">
<span className="text-cyan-200 text-xl flex-shrink-0"></span>
<p className="text-cyan-50">
<strong>Transparent Impact:</strong> Track exactly where your contribution
goes and the impact it creates.
</p>
</div>
</div>
</motion.div>
{/* Action Buttons */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="flex flex-col sm:flex-row gap-4 justify-center"
>
<a
href="/"
className="px-8 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-all hover:shadow-lg font-semibold text-center"
>
Try Again
</a>
<a
href="/"
className="px-8 py-3 bg-white text-slate-700 rounded-lg hover:bg-slate-50 transition-all hover:shadow-lg font-semibold border border-slate-200 text-center"
>
Return to Home
</a>
</motion.div>
{/* Help Section */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="mt-8 text-center"
>
<p className="text-slate-500 text-sm mb-2">Need help with your order?</p>
<a
href="mailto:support@puffinoffset.com"
className="text-blue-500 hover:text-blue-600 font-medium text-sm"
>
Contact Support
</a>
</motion.div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,234 @@
import { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import { getOrderDetails } from '../api/checkoutClient';
import { OrderDetailsResponse } from '../types';
export default function CheckoutSuccess() {
const [orderDetails, setOrderDetails] = useState<OrderDetailsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchOrderDetails = async () => {
// Get session ID from URL
const params = new URLSearchParams(window.location.search);
const sessionId = params.get('session_id');
if (!sessionId) {
setError('No session ID found in URL');
setLoading(false);
return;
}
try {
const details = await getOrderDetails(sessionId);
setOrderDetails(details);
} catch (err) {
setError('Failed to load order details');
console.error(err);
} finally {
setLoading(false);
}
};
fetchOrderDetails();
}, []);
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="text-center"
>
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-slate-600 font-medium">Loading your order details...</p>
</motion.div>
</div>
);
}
if (error || !orderDetails) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="max-w-md w-full bg-white rounded-2xl shadow-luxury p-8 text-center"
>
<div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-slate-800 mb-2">Order Not Found</h2>
<p className="text-slate-600 mb-6">{error || 'Unable to retrieve order details'}</p>
<a
href="/"
className="inline-block px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Return to Home
</a>
</motion.div>
</div>
);
}
const { order, session } = orderDetails;
const totalAmount = order.totalAmount / 100; // Convert cents to dollars
const baseAmount = order.baseAmount / 100;
const processingFee = order.processingFee / 100;
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-cyan-50 flex items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="max-w-2xl w-full"
>
{/* Success Header */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
className="text-center mb-8"
>
<div className="text-green-500 text-7xl mb-4"></div>
<h1 className="text-4xl md:text-5xl font-bold text-slate-800 mb-2">
Payment Successful!
</h1>
<p className="text-xl text-slate-600">
Thank you for offsetting your carbon footprint
</p>
</motion.div>
{/* Order Details Card */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-white rounded-2xl shadow-luxury p-8 mb-6"
>
<h2 className="text-2xl font-bold text-slate-800 mb-6">Order Summary</h2>
<div className="space-y-4">
{/* Offset Amount */}
<div className="flex justify-between items-center py-3 border-b border-slate-100">
<span className="text-slate-600 font-medium">Carbon Offset</span>
<span className="text-slate-800 font-bold text-lg">
{order.tons} tons CO
</span>
</div>
{/* Portfolio */}
<div className="flex justify-between items-center py-3 border-b border-slate-100">
<span className="text-slate-600 font-medium">Portfolio</span>
<span className="text-slate-800 font-semibold">
Portfolio #{order.portfolioId}
</span>
</div>
{/* Base Amount */}
<div className="flex justify-between items-center py-3">
<span className="text-slate-600">Offset Cost</span>
<span className="text-slate-800 font-semibold">
${baseAmount.toFixed(2)}
</span>
</div>
{/* Processing Fee */}
<div className="flex justify-between items-center py-3 border-b border-slate-200">
<span className="text-slate-600">Processing Fee (3%)</span>
<span className="text-slate-800 font-semibold">
${processingFee.toFixed(2)}
</span>
</div>
{/* Total */}
<div className="flex justify-between items-center py-4 bg-gradient-to-r from-blue-50 to-cyan-50 rounded-lg px-4">
<span className="text-slate-800 font-bold text-lg">Total Paid</span>
<span className="text-blue-600 font-bold text-2xl">
${totalAmount.toFixed(2)}
</span>
</div>
</div>
{/* Order Info */}
<div className="mt-6 pt-6 border-t border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500">Order ID</span>
<p className="text-slate-800 font-mono text-xs mt-1">{order.id}</p>
</div>
<div>
<span className="text-slate-500">Status</span>
<p className="mt-1">
<span className={`inline-block px-3 py-1 rounded-full text-xs font-semibold ${
order.status === 'fulfilled'
? 'bg-green-100 text-green-700'
: order.status === 'paid'
? 'bg-blue-100 text-blue-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{order.status.toUpperCase()}
</span>
</p>
</div>
{session.customerEmail && (
<div className="md:col-span-2">
<span className="text-slate-500">Email</span>
<p className="text-slate-800 mt-1">{session.customerEmail}</p>
</div>
)}
</div>
</div>
</motion.div>
{/* Impact Message */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
className="bg-gradient-to-r from-green-500 to-emerald-500 rounded-2xl shadow-luxury p-6 text-white text-center mb-6"
>
<h3 className="text-2xl font-bold mb-2">🌍 Making an Impact</h3>
<p className="text-green-50 text-lg">
You've offset {order.tons} tons of CO₂ - equivalent to planting approximately{' '}
{Math.round(order.tons * 50)} trees!
</p>
</motion.div>
{/* Action Buttons */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="flex flex-col sm:flex-row gap-4 justify-center"
>
<a
href="/"
className="px-8 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-all hover:shadow-lg font-semibold text-center"
>
Return to Home
</a>
<button
onClick={() => window.print()}
className="px-8 py-3 bg-white text-slate-700 rounded-lg hover:bg-slate-50 transition-all hover:shadow-lg font-semibold border border-slate-200"
>
Print Receipt
</button>
</motion.div>
{/* Confirmation Email Notice */}
{session.customerEmail && (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="text-center text-slate-500 text-sm mt-6"
>
A confirmation email has been sent to {session.customerEmail}
</motion.p>
)}
</motion.div>
</div>
);
}

View File

@ -47,6 +47,12 @@ export interface Portfolio {
currency: CurrencyCode;
}
export type CertificationStatus =
| 'standard 2020'
| 'standard 2023'
| 'nonstandard'
| 'in progress';
export interface OffsetProject {
id: string;
name: string;
@ -54,7 +60,8 @@ export interface OffsetProject {
shortDescription: string;
imageUrl: string;
pricePerTon: number;
percentage?: number; // Added percentage field for project's contribution to portfolio
percentage?: number; // Project's contribution to portfolio
certificationStatus?: CertificationStatus; // Wren certification standard
location: string;
type: string;
verificationStandard: string;
@ -65,15 +72,71 @@ export interface OffsetProject {
};
}
export type OffsetOrderSource =
| 'subscription'
| 'flight'
| 'gift'
| 'custom-duration'
| 'one-off'
| 'referral-bonus'
| 'annual-incentive'
| 'public-api'
| 'special-offer';
export interface OffsetOrder {
id: string;
amountCharged: number; // Amount in cents
amountCharged: number; // Amount in cents (amount_paid_by_customer from API)
currency: CurrencyCode;
tons: number;
portfolio: Portfolio;
status: 'pending' | 'completed' | 'failed';
createdAt: string;
dryRun: boolean;
source?: OffsetOrderSource; // Source of the payment
note?: string; // Optional note attached to the order
}
export type CalculatorType = 'trip' | 'annual';
// Stripe Checkout Types
export interface CheckoutSession {
sessionId: string;
url: string;
orderId: string;
}
export interface StoredOrder {
id: string;
stripeSessionId: string;
stripePaymentIntent: string | null;
wrenOrderId: string | null;
customerEmail: string | null;
tons: number;
portfolioId: number;
baseAmount: number; // in cents
processingFee: number; // in cents
totalAmount: number; // in cents
currency: CurrencyCode;
status: 'pending' | 'paid' | 'fulfilled' | 'failed';
createdAt: string;
updatedAt: string;
}
export interface OrderDetailsResponse {
order: {
id: string;
tons: number;
portfolioId: number;
baseAmount: number;
processingFee: number;
totalAmount: number;
currency: string;
status: string;
wrenOrderId: string | null;
createdAt: string;
};
session: {
paymentStatus: string;
customerEmail?: string;
};
}

View File

@ -0,0 +1,44 @@
/**
* Portfolio Color Palette System
* Provides colors for visualizing portfolio allocations across multiple projects.
*/
export const portfolioColorPalette = [
'#3B82F6', // Blue 500
'#06B6D4', // Cyan 500
'#10B981', // Green 500
'#14B8A6', // Teal 500
'#8B5CF6', // Violet 500
'#6366F1', // Indigo 500
'#0EA5E9', // Sky 500
'#22C55E', // Green 400
'#84CC16', // Lime 500
'#F59E0B', // Amber 500
'#EC4899', // Pink 500
'#EF4444', // Red 500
];
export function getProjectColor(index: number): string {
return portfolioColorPalette[index % portfolioColorPalette.length];
}
export function getProjectColors(count: number): string[] {
return Array.from({ length: count }, (_, i) => getProjectColor(i));
}
export function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
export function hexToRgba(hex: string, opacity: number): string {
const rgb = hexToRgb(hex);
if (!rgb) return hex;
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${opacity})`;
}

View File

@ -5,7 +5,28 @@ export default {
'./src/**/*.{js,ts,jsx,tsx}',
],
theme: {
extend: {},
extend: {
colors: {
'puffin-blue': {
DEFAULT: '#005A9C',
light: '#E6F0F6',
focus: '#3B82F6',
},
'puffin-gray': {
label: '#6B7280',
border: '#D1D5DB',
disabled: '#F3F4F6',
},
'puffin-error': {
DEFAULT: '#EF4444',
focus: '#F87171',
},
},
boxShadow: {
'focus-blue': '0 0 0 3px rgba(59, 130, 246, 0.3)',
'focus-red': '0 0 0 3px rgba(239, 68, 68, 0.3)',
},
},
},
plugins: [],
safelist: [