diff --git a/react/src/api/.openapi-generator/FILES b/react/src/api/.openapi-generator/FILES index 4385ded6..dd22d307 100644 --- a/react/src/api/.openapi-generator/FILES +++ b/react/src/api/.openapi-generator/FILES @@ -37,6 +37,7 @@ models/InvoiceResponseData.ts models/PaymentMethodResponseData.ts models/PlanDetailResponseData.ts models/PlanEntitlementResponseData.ts +models/PlanGroupPlanDetailResponseData.ts models/PlanResponseData.ts models/PreviewObject.ts models/RuleConditionDetailResponseData.ts diff --git a/react/src/api/models/CompanyPlanDetailResponseData.ts b/react/src/api/models/CompanyPlanDetailResponseData.ts index cbcaff34..68f4cdeb 100644 --- a/react/src/api/models/CompanyPlanDetailResponseData.ts +++ b/react/src/api/models/CompanyPlanDetailResponseData.ts @@ -104,6 +104,12 @@ export interface CompanyPlanDetailResponseData { * @memberof CompanyPlanDetailResponseData */ id: string; + /** + * + * @type {boolean} + * @memberof CompanyPlanDetailResponseData + */ + isDefault: boolean; /** * * @type {BillingPriceResponseData} @@ -159,6 +165,7 @@ export function instanceOfCompanyPlanDetailResponseData( if (!("features" in value) || value["features"] === undefined) return false; if (!("icon" in value) || value["icon"] === undefined) return false; if (!("id" in value) || value["id"] === undefined) return false; + if (!("isDefault" in value) || value["isDefault"] === undefined) return false; if (!("name" in value) || value["name"] === undefined) return false; if (!("planType" in value) || value["planType"] === undefined) return false; if (!("updatedAt" in value) || value["updatedAt"] === undefined) return false; @@ -198,6 +205,7 @@ export function CompanyPlanDetailResponseDataFromJSONTyped( ), icon: json["icon"], id: json["id"], + isDefault: json["is_default"], monthlyPrice: json["monthly_price"] == null ? undefined @@ -236,6 +244,7 @@ export function CompanyPlanDetailResponseDataToJSON( ), icon: value["icon"], id: value["id"], + is_default: value["isDefault"], monthly_price: BillingPriceResponseDataToJSON(value["monthlyPrice"]), name: value["name"], plan_type: value["planType"], diff --git a/react/src/api/models/PlanGroupPlanDetailResponseData.ts b/react/src/api/models/PlanGroupPlanDetailResponseData.ts new file mode 100644 index 00000000..011f9a53 --- /dev/null +++ b/react/src/api/models/PlanGroupPlanDetailResponseData.ts @@ -0,0 +1,237 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Schematic API + * Schematic API + * + * The version of the OpenAPI document: 0.1 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { mapValues } from "../runtime"; +import type { FeatureDetailResponseData } from "./FeatureDetailResponseData"; +import { + FeatureDetailResponseDataFromJSON, + FeatureDetailResponseDataFromJSONTyped, + FeatureDetailResponseDataToJSON, +} from "./FeatureDetailResponseData"; +import type { PlanEntitlementResponseData } from "./PlanEntitlementResponseData"; +import { + PlanEntitlementResponseDataFromJSON, + PlanEntitlementResponseDataFromJSONTyped, + PlanEntitlementResponseDataToJSON, +} from "./PlanEntitlementResponseData"; +import type { BillingPriceResponseData } from "./BillingPriceResponseData"; +import { + BillingPriceResponseDataFromJSON, + BillingPriceResponseDataFromJSONTyped, + BillingPriceResponseDataToJSON, +} from "./BillingPriceResponseData"; +import type { BillingProductDetailResponseData } from "./BillingProductDetailResponseData"; +import { + BillingProductDetailResponseDataFromJSON, + BillingProductDetailResponseDataFromJSONTyped, + BillingProductDetailResponseDataToJSON, +} from "./BillingProductDetailResponseData"; + +/** + * + * @export + * @interface PlanGroupPlanDetailResponseData + */ +export interface PlanGroupPlanDetailResponseData { + /** + * + * @type {string} + * @memberof PlanGroupPlanDetailResponseData + */ + audienceType?: string | null; + /** + * + * @type {BillingProductDetailResponseData} + * @memberof PlanGroupPlanDetailResponseData + */ + billingProduct?: BillingProductDetailResponseData; + /** + * + * @type {number} + * @memberof PlanGroupPlanDetailResponseData + */ + companyCount: number; + /** + * + * @type {Date} + * @memberof PlanGroupPlanDetailResponseData + */ + createdAt: Date; + /** + * + * @type {string} + * @memberof PlanGroupPlanDetailResponseData + */ + description: string; + /** + * + * @type {Array} + * @memberof PlanGroupPlanDetailResponseData + */ + entitlements: Array; + /** + * + * @type {Array} + * @memberof PlanGroupPlanDetailResponseData + */ + features: Array; + /** + * + * @type {string} + * @memberof PlanGroupPlanDetailResponseData + */ + icon: string; + /** + * + * @type {string} + * @memberof PlanGroupPlanDetailResponseData + */ + id: string; + /** + * + * @type {boolean} + * @memberof PlanGroupPlanDetailResponseData + */ + isDefault: boolean; + /** + * + * @type {BillingPriceResponseData} + * @memberof PlanGroupPlanDetailResponseData + */ + monthlyPrice?: BillingPriceResponseData; + /** + * + * @type {string} + * @memberof PlanGroupPlanDetailResponseData + */ + name: string; + /** + * + * @type {string} + * @memberof PlanGroupPlanDetailResponseData + */ + planType: string; + /** + * + * @type {Date} + * @memberof PlanGroupPlanDetailResponseData + */ + updatedAt: Date; + /** + * + * @type {BillingPriceResponseData} + * @memberof PlanGroupPlanDetailResponseData + */ + yearlyPrice?: BillingPriceResponseData; +} + +/** + * Check if a given object implements the PlanGroupPlanDetailResponseData interface. + */ +export function instanceOfPlanGroupPlanDetailResponseData( + value: object, +): value is PlanGroupPlanDetailResponseData { + if (!("companyCount" in value) || value["companyCount"] === undefined) + return false; + if (!("createdAt" in value) || value["createdAt"] === undefined) return false; + if (!("description" in value) || value["description"] === undefined) + return false; + if (!("entitlements" in value) || value["entitlements"] === undefined) + return false; + if (!("features" in value) || value["features"] === undefined) return false; + if (!("icon" in value) || value["icon"] === undefined) return false; + if (!("id" in value) || value["id"] === undefined) return false; + if (!("isDefault" in value) || value["isDefault"] === undefined) return false; + if (!("name" in value) || value["name"] === undefined) return false; + if (!("planType" in value) || value["planType"] === undefined) return false; + if (!("updatedAt" in value) || value["updatedAt"] === undefined) return false; + return true; +} + +export function PlanGroupPlanDetailResponseDataFromJSON( + json: any, +): PlanGroupPlanDetailResponseData { + return PlanGroupPlanDetailResponseDataFromJSONTyped(json, false); +} + +export function PlanGroupPlanDetailResponseDataFromJSONTyped( + json: any, + ignoreDiscriminator: boolean, +): PlanGroupPlanDetailResponseData { + if (json == null) { + return json; + } + return { + audienceType: + json["audience_type"] == null ? undefined : json["audience_type"], + billingProduct: + json["billing_product"] == null + ? undefined + : BillingProductDetailResponseDataFromJSON(json["billing_product"]), + companyCount: json["company_count"], + createdAt: new Date(json["created_at"]), + description: json["description"], + entitlements: (json["entitlements"] as Array).map( + PlanEntitlementResponseDataFromJSON, + ), + features: (json["features"] as Array).map( + FeatureDetailResponseDataFromJSON, + ), + icon: json["icon"], + id: json["id"], + isDefault: json["is_default"], + monthlyPrice: + json["monthly_price"] == null + ? undefined + : BillingPriceResponseDataFromJSON(json["monthly_price"]), + name: json["name"], + planType: json["plan_type"], + updatedAt: new Date(json["updated_at"]), + yearlyPrice: + json["yearly_price"] == null + ? undefined + : BillingPriceResponseDataFromJSON(json["yearly_price"]), + }; +} + +export function PlanGroupPlanDetailResponseDataToJSON( + value?: PlanGroupPlanDetailResponseData | null, +): any { + if (value == null) { + return value; + } + return { + audience_type: value["audienceType"], + billing_product: BillingProductDetailResponseDataToJSON( + value["billingProduct"], + ), + company_count: value["companyCount"], + created_at: value["createdAt"].toISOString(), + description: value["description"], + entitlements: (value["entitlements"] as Array).map( + PlanEntitlementResponseDataToJSON, + ), + features: (value["features"] as Array).map( + FeatureDetailResponseDataToJSON, + ), + icon: value["icon"], + id: value["id"], + is_default: value["isDefault"], + monthly_price: BillingPriceResponseDataToJSON(value["monthlyPrice"]), + name: value["name"], + plan_type: value["planType"], + updated_at: value["updatedAt"].toISOString(), + yearly_price: BillingPriceResponseDataToJSON(value["yearlyPrice"]), + }; +} diff --git a/react/src/api/models/index.ts b/react/src/api/models/index.ts index 2022c205..72145ebf 100644 --- a/react/src/api/models/index.ts +++ b/react/src/api/models/index.ts @@ -35,6 +35,7 @@ export * from "./InvoiceResponseData"; export * from "./PaymentMethodResponseData"; export * from "./PlanDetailResponseData"; export * from "./PlanEntitlementResponseData"; +export * from "./PlanGroupPlanDetailResponseData"; export * from "./PlanResponseData"; export * from "./PreviewObject"; export * from "./RuleConditionDetailResponseData"; diff --git a/react/src/components/elements/payment-method/PaymentMethod.tsx b/react/src/components/elements/payment-method/PaymentMethod.tsx index 38c3bbec..e7c5c797 100644 --- a/react/src/components/elements/payment-method/PaymentMethod.tsx +++ b/react/src/components/elements/payment-method/PaymentMethod.tsx @@ -45,10 +45,10 @@ export const PaymentMethod = forwardRef< const props = resolveDesignProps(rest); const theme = useTheme(); - const { data, stripe, layout } = useEmbed(); + const { data, layout } = useEmbed(); const paymentMethod = useMemo(() => { - const { cardLast4, cardExpMonth, cardExpYear } = + const { paymentMethodType, cardLast4, cardExpMonth, cardExpYear } = data.subscription?.paymentMethod || {}; let monthsToExpiration: number | undefined; @@ -66,6 +66,7 @@ export const PaymentMethod = forwardRef< } return { + paymentMethodType, cardLast4, monthsToExpiration, }; @@ -75,7 +76,7 @@ export const PaymentMethod = forwardRef< return hexToHSL(theme.card.background).l > 50; }, [theme.card.background]); - if (!stripe || !paymentMethod.cardLast4) { + if (!paymentMethod.paymentMethodType) { return null; } @@ -111,24 +112,24 @@ export const PaymentMethod = forwardRef< )} - {paymentMethod.cardLast4 && ( - - - 💳 Card ending in {paymentMethod.cardLast4} - - - )} + + + {paymentMethod.cardLast4 + ? `💳 Card ending in ${paymentMethod.cardLast4}` + : "Other existing payment method"} + + {layout === "payment" && createPortal( diff --git a/react/src/components/elements/plan-manager/CheckoutDialog.tsx b/react/src/components/elements/plan-manager/CheckoutDialog.tsx index 7585c117..308e408f 100644 --- a/react/src/components/elements/plan-manager/CheckoutDialog.tsx +++ b/react/src/components/elements/plan-manager/CheckoutDialog.tsx @@ -18,6 +18,7 @@ import { Text, type IconNameTypes, } from "../../ui"; +import { PaymentMethod } from "../payment-method"; import { PaymentForm } from "./PaymentForm"; import { StyledButton } from "./styles"; @@ -81,27 +82,58 @@ const FeatureName = ({ }; export const CheckoutDialog = () => { + const theme = useTheme(); + const { api, data, hydrate, setLayout } = useEmbed(); + const [checkoutStage, setCheckoutStage] = useState<"plan" | "checkout">( "plan", ); - const [planPeriod, setPlanPeriod] = useState<"month" | "year">("month"); + const [planPeriod, setPlanPeriod] = useState( + () => data.company?.plan?.planPeriod || "month", + ); const [selectedPlan, setSelectedPlan] = useState(); const [paymentMethodId, setPaymentMethodId] = useState(); const [isLoading, setIsLoading] = useState(false); const [isCheckoutComplete, setIsCheckoutComplete] = useState(false); const [error, setError] = useState(); + const [showPaymentForm, setShowPaymentForm] = useState( + () => typeof data.subscription?.paymentMethod === "undefined", + ); - const theme = useTheme(); - - const { api, data, hydrate, setLayout } = useEmbed(); - - const { currentPlan, availablePlans } = useMemo(() => { - return { - currentPlan: data.company?.plan, - availablePlans: data.activePlans, - }; - }, [data.company, data.activePlans]); + const { paymentMethod, currentPlan, availablePlans, planPeriodOptions } = + useMemo(() => { + const showMonthlyPriceOption = data.activePlans.some( + (plan) => typeof plan.yearlyPrice !== "undefined", + ); + const showYearlyPriceOption = data.activePlans.some( + (plan) => typeof plan.yearlyPrice !== "undefined", + ); + const planPeriodOptions = []; + if (showMonthlyPriceOption) { + planPeriodOptions.push("month"); + } + if (showYearlyPriceOption) { + planPeriodOptions.push("year"); + } + + return { + paymentMethod: data.subscription?.paymentMethod, + currentPlan: data.company?.plan, + availablePlans: data.activePlans.filter( + (plan) => + plan.current || + (plan.yearlyPrice && planPeriod === "year") || + (plan.monthlyPrice && planPeriod === "month"), + ), + planPeriodOptions, + }; + }, [ + data.subscription?.paymentMethod, + data.company, + data.activePlans, + planPeriod, + ]); const savingsPercentage = useMemo(() => { if (selectedPlan) { @@ -124,6 +156,13 @@ export const CheckoutDialog = () => { } }, [isCheckoutComplete, api, data.component?.id, hydrate]); + const allowCheckout = + api && + selectedPlan && + selectedPlan?.id !== currentPlan?.id && + ((paymentMethod && !showPaymentForm) || paymentMethodId) && + !isLoading; + return ( @@ -469,13 +508,54 @@ export const CheckoutDialog = () => { )} {selectedPlan && checkoutStage === "checkout" && ( - { - setPaymentMethodId(value); - }} - /> + <> + {showPaymentForm ? ( + <> + { + setPaymentMethodId(value); + }} + /> + {typeof data.subscription?.paymentMethod !== + "undefined" && ( + setShowPaymentForm(false)} + $cursor="pointer" + > + + Use existing payment method + + + )} + + ) : ( + <> + + setShowPaymentForm(true)} + $cursor="pointer" + > + + Change payment method + + + + )} + )} @@ -513,62 +593,64 @@ export const CheckoutDialog = () => { - + {planPeriodOptions.length > 1 && ( setPlanPeriod("month")} - $justifyContent="center" - $alignItems="center" - $padding="0.25rem 0.5rem" - $flex="1" - {...(planPeriod === "month" && { - $backgroundColor: isLightBackground - ? "hsla(0, 0%, 0%, 0.075)" - : "hsla(0, 0%, 100%, 0.15)", - })} + $borderWidth="1px" + $borderStyle="solid" + $borderColor={ + isLightBackground + ? "hsla(0, 0%, 0%, 0.1)" + : "hsla(0, 0%, 100%, 0.2)" + } $borderRadius="2.5rem" + $cursor="pointer" > - setPlanPeriod("month")} + $justifyContent="center" + $alignItems="center" + $padding="0.25rem 0.5rem" + $flex="1" + {...(planPeriod === "month" && { + $backgroundColor: isLightBackground + ? "hsla(0, 0%, 0%, 0.075)" + : "hsla(0, 0%, 100%, 0.15)", + })} + $borderRadius="2.5rem" > - Billed monthly - - - setPlanPeriod("year")} - $justifyContent="center" - $alignItems="center" - $padding="0.25rem 0.5rem" - $flex="1" - {...(planPeriod === "year" && { - $backgroundColor: isLightBackground - ? "hsla(0, 0%, 0%, 0.075)" - : "hsla(0, 0%, 100%, 0.15)", - })} - $borderRadius="2.5rem" - > - + Billed monthly + + + setPlanPeriod("year")} + $justifyContent="center" + $alignItems="center" + $padding="0.25rem 0.5rem" + $flex="1" + {...(planPeriod === "year" && { + $backgroundColor: isLightBackground + ? "hsla(0, 0%, 0%, 0.075)" + : "hsla(0, 0%, 100%, 0.15)", + })} + $borderRadius="2.5rem" > - Billed yearly - + + Billed yearly + + - + )} {savingsPercentage > 0 && ( @@ -744,7 +826,6 @@ export const CheckoutDialog = () => { {...(selectedPlan && { onClick: () => setCheckoutStage("checkout"), })} - $size="sm" > { $alignItems="center" $padding="0 1rem" > - Next: Checkout + + Next: Checkout + ) : ( { - const priceId = ( - planPeriod === "month" - ? selectedPlan?.monthlyPrice - : selectedPlan?.yearlyPrice - )?.id; - if (!api || !selectedPlan || !priceId || !paymentMethodId) { - return; - } + {...(allowCheckout + ? { + onClick: async () => { + const priceId = ( + planPeriod === "month" + ? selectedPlan?.monthlyPrice + : selectedPlan?.yearlyPrice + )?.id; + if (!priceId) { + return; + } - try { - setIsLoading(true); - setIsCheckoutComplete(false); - await api.checkout({ - changeSubscriptionRequestBody: { - newPlanId: selectedPlan.id, - newPriceId: priceId, - paymentMethodId: paymentMethodId, + try { + setIsLoading(true); + setIsCheckoutComplete(false); + await api.checkout({ + changeSubscriptionRequestBody: { + newPlanId: selectedPlan.id, + newPriceId: priceId, + ...(paymentMethodId && { paymentMethodId }), + }, + }); + setIsCheckoutComplete(true); + } catch { + setError( + "Error processing payment. Please try a different payment method.", + ); + } finally { + setIsLoading(false); + } }, - }); - // throw new Error("Test error."); - setIsCheckoutComplete(true); - } catch { - setError( - "Error processing payment. Please try a different payment method.", - ); - } finally { - setIsLoading(false); - } - }} - $size="md" + } + : { disabled: true })} > Pay now diff --git a/react/src/components/elements/plan-manager/PaymentForm.tsx b/react/src/components/elements/plan-manager/PaymentForm.tsx index e562d780..35fd09e5 100644 --- a/react/src/components/elements/plan-manager/PaymentForm.tsx +++ b/react/src/components/elements/plan-manager/PaymentForm.tsx @@ -11,7 +11,7 @@ import { StyledButton } from "./styles"; interface PaymentFormProps { plan: CompanyPlanDetailResponseData; - period: "month" | "year"; + period: string; onConfirm?: (paymentMethodId: string) => void; } @@ -73,7 +73,6 @@ export const PaymentForm = ({ plan, period, onConfirm }: PaymentFormProps) => { style={{ display: "flex", flexDirection: "column", - height: "100%", overflowX: "hidden", overflowY: "auto", }} diff --git a/react/src/components/ui/modal/Modal.tsx b/react/src/components/ui/modal/Modal.tsx index 2eec4da7..82fd1384 100644 --- a/react/src/components/ui/modal/Modal.tsx +++ b/react/src/components/ui/modal/Modal.tsx @@ -51,7 +51,7 @@ export const Modal = ({ children, size = "auto", onClose }: ModalProps) => { $width="100%" $height="100%" $backgroundColor={ - isLightBackground ? "hsl(0, 0%, 85%)" : "hsl(0, 0%, 15%)" + isLightBackground ? "hsla(0, 0%, 85%, 0.8)" : "hsla(0, 0%, 15%, 0.8)" } $overflow="hidden" > diff --git a/react/src/context/embed.tsx b/react/src/context/embed.tsx index a2d0591f..f5e5ee19 100644 --- a/react/src/context/embed.tsx +++ b/react/src/context/embed.tsx @@ -696,13 +696,13 @@ export const EmbedProvider = ({ theme: "stripe", variables: { // Base - spacingUnit: ".25rem", - colorPrimary: "#0570de", + fontFamily: '"Public Sans", system-ui, sans-serif', + spacingUnit: "0.25rem", + borderRadius: "0.5rem", + colorText: "#30313D", colorBackground: "#FFFFFF", - colorText: "#30313d", - colorDanger: "#df1b41", - fontFamily: "Public Sans, system-ui, sans-serif", - borderRadius: ".5rem", + colorPrimary: "#0570DE", + colorDanger: "#DF1B41", // Layout gridRowSpacing: "1.5rem", @@ -710,10 +710,10 @@ export const EmbedProvider = ({ }, rules: { ".Label": { - color: "#020202", + fontSize: "1rem", fontWeight: "400", - fontSize: "16px", - marginBottom: "12px", + marginBottom: "0.75rem", + color: state.settings.theme.typography.text.color, }, }, },