Integrate Stripe Checkout and add comprehensive UI enhancements
All checks were successful
Build and Push Docker Image / docker (push) Successful in 42s
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:
parent
3a33221130
commit
06733cb2cb
@ -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
14250
docs/stripe-api-setup.md
Normal file
File diff suppressed because it is too large
Load Diff
509
docs/stripe-appearance-customization.md
Normal file
509
docs/stripe-appearance-customization.md
Normal 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 doesn’t 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.
|
||||

|
||||
|
||||
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 that’s 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 isn’t 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 it’s 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 user’s 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
|
||||

|
||||
|
||||
| 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)
|
||||

|
||||
|
||||
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)
|
||||

|
||||
|
||||
> 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
|
||||

|
||||
|
||||
| Class name | States | Pseudo-classes | Pseudo-elements |
|
||||
| --------------- | ------------ | ----------------------------- | --------------- |
|
||||
| `.Block` | | | |
|
||||
| `.BlockDivider` | | | |
|
||||
| `.BlockAction` | `--negative` | `:hover`, `:focus`, `:active` | |
|
||||
|
||||
### Code Input
|
||||

|
||||
|
||||
| Class name | States | Pseudo-classes | Pseudo-elements |
|
||||
| ------------ | ------ | ------------------------------- | --------------- |
|
||||
| `.CodeInput` | | `:hover`, `:focus`, `:disabled` | |
|
||||
|
||||
### Checkbox
|
||||

|
||||
|
||||
| Class name | States | Pseudo-classes | Pseudo-elements |
|
||||
| ---------------- | ----------- | ------------------------------------ | --------------- |
|
||||
| `.Checkbox` | `--checked` | | |
|
||||
| `.CheckboxLabel` | `--checked` | `:hover`, `:focus`, `:focus-visible` | |
|
||||
| `.CheckboxInput` | `--checked` | `:hover`, `:focus`, `:focus-visible` | |
|
||||
|
||||
### Dropdown
|
||||

|
||||
|
||||
| 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 can’t style system dropdowns, such as the country selector, using these rules because of operating system restrictions.
|
||||
|
||||
### Switch
|
||||

|
||||
|
||||
| Class name | States | Pseudo-classes | Pseudo-elements |
|
||||
| ---------------- | ---------- | -------------- | --------------- |
|
||||
| `.Switch` | `--active` | `:hover` | |
|
||||
| `.SwitchControl` | | `:hover` | |
|
||||
|
||||
### Picker
|
||||

|
||||
|
||||
| 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.
|
||||
|
||||
|  |  |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **DO**
|
||||
|
||||
Use a noticeable, high-contrast primary color, weight, and/or outline to distinguish the active state your customer has already selected. | **DON’T**
|
||||
|
||||
Don’t 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
|
||||

|
||||
|
||||
| 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` isn’t 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
15
server/.env.example
Normal 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
9
server/.gitignore
vendored
Normal 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
73
server/config/database.js
Normal 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
17
server/config/stripe.js
Normal 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
80
server/index.js
Normal 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
166
server/models/Order.js
Normal 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
1432
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
server/package.json
Normal file
27
server/package.json
Normal 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
177
server/routes/checkout.js
Normal 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
176
server/routes/webhooks.js
Normal 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;
|
||||
93
server/utils/wrenClient.js
Normal file
93
server/utils/wrenClient.js
Normal 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
|
||||
};
|
||||
59
src/App.tsx
59
src/App.tsx
@ -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
100
src/api/checkoutClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
74
src/components/CertificationBadge.tsx
Normal file
74
src/components/CertificationBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -113,7 +113,6 @@ export function MobileCalculator({ vesselData, onOffsetClick, onBack }: Props) {
|
||||
<MobileOffsetOrder
|
||||
tons={offsetTons}
|
||||
monetaryAmount={monetaryAmount}
|
||||
currency={currency}
|
||||
onBack={() => setShowOffsetOrder(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
346
src/components/PortfolioDonutChart.tsx
Normal file
346
src/components/PortfolioDonutChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/components/RadialProgress.tsx
Normal file
88
src/components/RadialProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
|
||||
99
src/components/forms/FormFieldWrapper.tsx
Normal file
99
src/components/forms/FormFieldWrapper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/forms/FormInput.tsx
Normal file
64
src/components/forms/FormInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/components/forms/FormSelect.tsx
Normal file
115
src/components/forms/FormSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/forms/FormTextarea.tsx
Normal file
64
src/components/forms/FormTextarea.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/pages/CheckoutCancel.tsx
Normal file
124
src/pages/CheckoutCancel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
234
src/pages/CheckoutSuccess.tsx
Normal file
234
src/pages/CheckoutSuccess.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/types.ts
67
src/types.ts
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
44
src/utils/portfolioColors.ts
Normal file
44
src/utils/portfolioColors.ts
Normal 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})`;
|
||||
}
|
||||
@ -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: [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user