From fe9db1553a17282dfcca58820b13ebce675d042c Mon Sep 17 00:00:00 2001 From: Joseph Chrzan Date: Mon, 16 Sep 2024 20:53:42 -0400 Subject: [PATCH] rc up (#54) * update checkout styles * big styles and try to rehydrate on checkout * fix leloader * whoops * cleanup * reset error before fetching * temp * Refactor useSchematicFlag to use useSyncExternalStore and add useSchematicIsPending hook * Improve SchematicContext to ensure client exists * Fix error state display * update schematic-js * finish? styles * version up * cleanup --------- Co-authored-by: Ben Papillon --- react/.eslintrc.cjs | 5 +- react/package.json | 4 +- .../included-features/IncludedFeatures.tsx | 78 ++- .../components/elements/invoices/Invoices.tsx | 61 +-- .../metered-features/MeteredFeatures.tsx | 70 +-- .../elements/payment-method/PaymentMethod.tsx | 32 +- .../elements/plan-manager/CheckoutDialog.tsx | 475 +++++++++++------- .../elements/plan-manager/PaymentForm.tsx | 60 +-- .../elements/plan-manager/PlanManager.tsx | 61 +-- .../elements/plan-manager/styles.ts | 26 +- .../elements/upcoming-bill/UpcomingBill.tsx | 45 +- react/src/components/embed/ComponentTree.tsx | 8 +- react/src/components/layout/card/Card.tsx | 12 +- react/src/components/layout/card/styles.ts | 6 +- .../components/layout/viewport/Viewport.tsx | 29 +- react/src/components/ui/icon/IconRound.tsx | 2 +- react/src/components/ui/icon/styles.ts | 23 +- react/src/components/ui/modal/Modal.tsx | 32 +- react/src/components/ui/modal/ModalHeader.tsx | 42 +- .../ui/progress-bar/ProgressBar.tsx | 6 +- react/src/context/embed.tsx | 174 ++++--- react/src/context/schematic.tsx | 84 ++-- react/src/hooks/schematic.ts | 82 +-- react/src/index.ts | 4 +- react/src/utils/color.ts | 7 +- 25 files changed, 774 insertions(+), 654 deletions(-) diff --git a/react/.eslintrc.cjs b/react/.eslintrc.cjs index dcc36401..49163df0 100644 --- a/react/.eslintrc.cjs +++ b/react/.eslintrc.cjs @@ -16,6 +16,9 @@ module.exports = { plugins: ["import"], rules: { "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/no-unused-vars": ["error", {ignoreRestSiblings: true}], + "@typescript-eslint/no-unused-vars": [ + "error", + { ignoreRestSiblings: true }, + ], }, }; diff --git a/react/package.json b/react/package.json index e4f7403a..bbc47507 100644 --- a/react/package.json +++ b/react/package.json @@ -1,6 +1,6 @@ { "name": "@schematichq/schematic-react", - "version": "0.2.0-rc.5", + "version": "0.2.0-rc.6", "main": "dist/schematic-react.cjs.js", "module": "dist/schematic-react.esm.js", "types": "dist/schematic-react.d.ts", @@ -29,7 +29,7 @@ "tsc": "npx tsc" }, "dependencies": { - "@schematichq/schematic-js": "^0.1.13", + "@schematichq/schematic-js": "^0.1.14", "@stripe/react-stripe-js": "^2.8.0", "@stripe/stripe-js": "^4.3.0", "lodash.merge": "^4.6.2", diff --git a/react/src/components/elements/included-features/IncludedFeatures.tsx b/react/src/components/elements/included-features/IncludedFeatures.tsx index 3956ce5f..8308a399 100644 --- a/react/src/components/elements/included-features/IncludedFeatures.tsx +++ b/react/src/components/elements/included-features/IncludedFeatures.tsx @@ -1,8 +1,9 @@ import { forwardRef, useMemo } from "react"; +import { useTheme } from "styled-components"; import { useEmbed } from "../../../hooks"; import { type FontStyle } from "../../../context"; import type { RecursivePartial, ElementProps } from "../../../types"; -import { lighten, darken, hexToHSL } from "../../../utils"; +import { hexToHSL } from "../../../utils"; import { Box, Flex, IconRound, Text, type IconNameTypes } from "../../ui"; interface DesignProps { @@ -35,12 +36,12 @@ function resolveDesignProps(props: RecursivePartial): DesignProps { }, icons: { isVisible: props.icons?.isVisible ?? true, - fontStyle: props.icons?.fontStyle ?? "heading3", + fontStyle: props.icons?.fontStyle ?? "heading5", style: props.icons?.style ?? "light", }, entitlement: { isVisible: props.entitlement?.isVisible ?? true, - fontStyle: props.entitlement?.fontStyle ?? "heading5", + fontStyle: props.entitlement?.fontStyle ?? "text", }, usage: { isVisible: props.usage?.isVisible ?? true, @@ -59,7 +60,8 @@ export const IncludedFeatures = forwardRef< >(({ className, ...rest }, ref) => { const props = resolveDesignProps(rest); - const { data, settings } = useEmbed(); + const theme = useTheme(); + const { data } = useEmbed(); const features = useMemo(() => { return (data.featureUsage?.features || []).map( @@ -89,17 +91,19 @@ export const IncludedFeatures = forwardRef< ); }, [data.featureUsage]); + const isLightBackground = useMemo(() => { + return hexToHSL(theme.card.background).l > 50; + }, [theme.card.background]); + return ( {props.header.isVisible && ( {props.header.text} @@ -131,8 +135,10 @@ export const IncludedFeatures = forwardRef< name={feature.icon as IconNameTypes} size="sm" colors={[ - settings.theme.primary, - `${hexToHSL(settings.theme.card.background).l > 50 ? darken(settings.theme.card.background, 10) : lighten(settings.theme.card.background, 20)}`, + theme.primary, + isLightBackground + ? "hsla(0, 0%, 0%, 0.0625)" + : "hsla(0, 0%, 100%, 0.25)", ]} /> )} @@ -140,21 +146,12 @@ export const IncludedFeatures = forwardRef< {feature?.name && ( {feature.name} @@ -168,20 +165,17 @@ export const IncludedFeatures = forwardRef< {typeof allocation === "number" @@ -193,21 +187,13 @@ export const IncludedFeatures = forwardRef< {props.usage.isVisible && ( {typeof allocation === "number" ? `${usage} of ${allocation} used` diff --git a/react/src/components/elements/invoices/Invoices.tsx b/react/src/components/elements/invoices/Invoices.tsx index e256982c..097a4d09 100644 --- a/react/src/components/elements/invoices/Invoices.tsx +++ b/react/src/components/elements/invoices/Invoices.tsx @@ -1,5 +1,5 @@ import { forwardRef, useMemo } from "react"; -import { useEmbed } from "../../../hooks"; +import { useTheme } from "styled-components"; import { type FontStyle } from "../../../context"; import type { RecursivePartial, ElementProps } from "../../../types"; import { toPrettyDate } from "../../../utils"; @@ -63,7 +63,7 @@ export const Invoices = forwardRef< >(({ className, ...rest }, ref) => { const props = resolveDesignProps(rest); - const { settings } = useEmbed(); + const theme = useTheme(); const { invoices } = useMemo(() => { /** @@ -89,14 +89,10 @@ export const Invoices = forwardRef< {props.header.isVisible && ( Invoices @@ -114,20 +110,12 @@ export const Invoices = forwardRef< {props.date.isVisible && ( {toPrettyDate(date)} @@ -136,20 +124,13 @@ export const Invoices = forwardRef< {props.amount.isVisible && ( ${amount} @@ -164,16 +145,10 @@ export const Invoices = forwardRef< See all diff --git a/react/src/components/elements/metered-features/MeteredFeatures.tsx b/react/src/components/elements/metered-features/MeteredFeatures.tsx index d4e5ddec..e7cedc0d 100644 --- a/react/src/components/elements/metered-features/MeteredFeatures.tsx +++ b/react/src/components/elements/metered-features/MeteredFeatures.tsx @@ -1,4 +1,5 @@ import { forwardRef, useMemo } from "react"; +import { useTheme } from "styled-components"; import { useEmbed } from "../../../hooks"; import { type FontStyle } from "../../../context"; import type { RecursivePartial, ElementProps } from "../../../types"; @@ -77,7 +78,8 @@ export const MeteredFeatures = forwardRef< >(({ className, ...rest }, ref) => { const props = resolveDesignProps(rest); - const { data, settings } = useEmbed(); + const theme = useTheme(); + const { data } = useEmbed(); const features = useMemo(() => { return (data.featureUsage?.features || []).map( @@ -139,21 +141,15 @@ export const MeteredFeatures = forwardRef< {feature.name} @@ -162,24 +158,19 @@ export const MeteredFeatures = forwardRef< {feature.description} @@ -194,24 +185,19 @@ export const MeteredFeatures = forwardRef< {typeof allocation === "number" @@ -224,21 +210,15 @@ export const MeteredFeatures = forwardRef< {typeof allocation === "number" ? `${usage} of ${allocation} used` diff --git a/react/src/components/elements/payment-method/PaymentMethod.tsx b/react/src/components/elements/payment-method/PaymentMethod.tsx index 3b342960..6a6cd20d 100644 --- a/react/src/components/elements/payment-method/PaymentMethod.tsx +++ b/react/src/components/elements/payment-method/PaymentMethod.tsx @@ -1,10 +1,11 @@ import { forwardRef, useMemo } from "react"; import { createPortal } from "react-dom"; +import { useTheme } from "styled-components"; import { useEmbed } from "../../../hooks"; import { type FontStyle } from "../../../context"; import type { RecursivePartial, ElementProps } from "../../../types"; import { Box, Flex, Modal, ModalHeader, Text } from "../../ui"; -import { darken, lighten, hexToHSL } from "../../../utils"; +import { hexToHSL } from "../../../utils"; import { StyledButton } from "../plan-manager/styles"; interface DesignProps { @@ -43,7 +44,8 @@ export const PaymentMethod = forwardRef< >(({ children, className, portal, ...rest }, ref) => { const props = resolveDesignProps(rest); - const { data, settings, stripe, layout } = useEmbed(); + const theme = useTheme(); + const { data, stripe, layout } = useEmbed(); const paymentMethod = useMemo(() => { const { cardLast4, cardExpMonth, cardExpYear } = @@ -65,7 +67,11 @@ export const PaymentMethod = forwardRef< }; }, [data.subscription?.paymentMethod]); - if (!stripe || !data.subscription?.paymentMethod) { + const isLightBackground = useMemo(() => { + return hexToHSL(theme.card.background).l > 50; + }, [theme.card.background]); + + if (!stripe || !paymentMethod.cardLast4) { return null; } @@ -78,12 +84,10 @@ export const PaymentMethod = forwardRef< $margin="0 0 1rem" > Payment Method @@ -91,7 +95,7 @@ export const PaymentMethod = forwardRef< {typeof paymentMethod.monthsToExpiration === "number" && Math.abs(paymentMethod.monthsToExpiration) < 4 && ( @@ -108,11 +112,15 @@ export const PaymentMethod = forwardRef< $justifyContent="space-between" $alignItems="center" $margin="0 0 1rem" - $background={`${hexToHSL(settings.theme.card.background).l > 50 ? darken(settings.theme.card.background, 10) : lighten(settings.theme.card.background, 20)}`} + $backgroundColor={ + isLightBackground + ? "hsla(0, 0%, 0%, 0.0625)" + : "hsla(0, 0%, 100%, 0.125)" + } $padding="0.375rem 1rem" $borderRadius="9999px" > - + 💳 Card ending in {paymentMethod.cardLast4} diff --git a/react/src/components/elements/plan-manager/CheckoutDialog.tsx b/react/src/components/elements/plan-manager/CheckoutDialog.tsx index cec2b91e..a429fd98 100644 --- a/react/src/components/elements/plan-manager/CheckoutDialog.tsx +++ b/react/src/components/elements/plan-manager/CheckoutDialog.tsx @@ -1,8 +1,9 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useTheme } from "styled-components"; import type { CompanyPlanDetailResponseData } from "../../../api"; +import { TEXT_BASE_SIZE } from "../../../const"; import { useEmbed } from "../../../hooks"; -import { lighten, darken, hexToHSL, formatCurrency } from "../../../utils"; +import { hexToHSL, formatCurrency } from "../../../utils"; import { Box, Flex, @@ -26,10 +27,11 @@ export const CheckoutDialog = () => { const [paymentMethodId, setPaymentMethodId] = useState(); const [isLoading, setIsLoading] = useState(false); const [isCheckoutComplete, setIsCheckoutComplete] = useState(false); + const [error, setError] = useState(); const theme = useTheme(); - const { api, data, settings } = useEmbed(); + const { api, data, hydrate, setLayout } = useEmbed(); const { currentPlan, availablePlans } = useMemo(() => { return { @@ -48,91 +50,109 @@ export const CheckoutDialog = () => { return 0; }, [selectedPlan]); + const isLightBackground = useMemo(() => { + return hexToHSL(theme.card.background).l > 50; + }, [theme.card.background]); + + // TODO: reload component after checkout + useEffect(() => { + if (isCheckoutComplete && api && data.component?.id) { + hydrate(); + } + }, [isCheckoutComplete, api, data.component?.id, hydrate]); + return ( - - + + {!isCheckoutComplete && ( <> {checkoutStage === "plan" ? ( 50 - ? darken(theme.card.background, 12.5) - : lighten(theme.card.background, 12.5) + isLightBackground + ? "hsla(0, 0%, 0%, 0.125)" + : "hsla(0, 0%, 100%, 0.25)" } $borderRadius="9999px" /> ) : ( 50 - ? darken(theme.card.background, 12.5) - : lighten(theme.card.background, 12.5), - fontSize: 16, - width: "1rem", - height: "1rem", + fontSize: `${16 / TEXT_BASE_SIZE}rem`, + width: `${20 / TEXT_BASE_SIZE}rem`, + height: `${20 / TEXT_BASE_SIZE}rem`, }} /> )} setCheckoutStage("plan"), - })} + {...(checkoutStage !== "plan" && { + onClick: () => setCheckoutStage("plan"), + $opacity: "0.6375", + $cursor: "pointer", + })} > - 1. Select plan + + 1. Select plan + - 50 - ? darken(theme.card.background, 17.5) - : lighten(theme.card.background, 17.5), - }} - /> + + + 50 - ? darken(theme.card.background, 12.5) - : lighten(theme.card.background, 12.5) + isLightBackground + ? "hsla(0, 0%, 0%, 0.125)" + : "hsla(0, 0%, 100%, 0.25)" } $borderRadius="9999px" /> - 2. Checkout + + 2. Checkout + @@ -141,7 +161,25 @@ export const CheckoutDialog = () => { {isCheckoutComplete && ( - + + { > Subscription updated! + setLayout("portal")}>Close )} {!isCheckoutComplete && ( - + {checkoutStage === "plan" && ( <> Select plan @@ -183,22 +225,25 @@ export const CheckoutDialog = () => { Choose your base plan - + {availablePlans?.map((plan) => { return ( { ? theme.primary : "transparent" } - $borderRadius={`${settings.theme.card.borderRadius / 16}rem`} - {...(settings.theme.card.hasShadow && { + $borderRadius={`${theme.card.borderRadius / TEXT_BASE_SIZE}rem`} + {...(theme.card.hasShadow && { $boxShadow: "0px 1px 3px rgba(16, 24, 40, 0.1), 0px 1px 20px rgba(16, 24, 40, 0.06)", })} @@ -218,13 +263,13 @@ export const CheckoutDialog = () => { $gap="1rem" $width="100%" $height="auto" - $padding={`${settings.theme.card.padding / 16}rem`} + $padding={`${theme.card.padding / TEXT_BASE_SIZE}rem`} $borderBottomWidth="1px" $borderStyle="solid" $borderColor={ - hexToHSL(theme.card.background).l > 50 - ? darken(theme.card.background, 17.5) - : lighten(theme.card.background, 17.5) + isLightBackground + ? "hsla(0, 0%, 0%, 0.175)" + : "hsla(0, 0%, 100%, 0.175)" } > @@ -283,8 +328,10 @@ export const CheckoutDialog = () => { name={feature.icon as IconNameTypes} size="tn" colors={[ - settings.theme.primary, - `${hexToHSL(settings.theme.card.background).l > 50 ? darken(settings.theme.card.background, 10) : lighten(settings.theme.card.background, 20)}`, + theme.primary, + isLightBackground + ? "hsla(0, 0%, 0%, 0.0625)" + : "hsla(0, 0%, 100%, 0.0625)", ]} /> @@ -321,13 +368,8 @@ export const CheckoutDialog = () => { /> 50 - ? "#000000" - : "#FFFFFF", - lineHeight: "1.4", - }} + $lineHeight="1.4" + $color={theme.typography.text.color} > Selected @@ -338,12 +380,12 @@ export const CheckoutDialog = () => { plan.id !== selectedPlan?.id && ( setSelectedPlan(plan), + })} $size="sm" $color="primary" $variant="outline" - onClick={() => { - setSelectedPlan(plan); - }} > Select @@ -368,14 +410,10 @@ export const CheckoutDialog = () => { { $width="100%" $height="auto" $padding="1.5rem" - $borderBottom="1px solid #DEDEDE" + $borderBottomWidth="1px" + $borderStyle="solid" + $borderColor={ + isLightBackground + ? "hsla(0, 0%, 0%, 0.1)" + : "hsla(0, 0%, 100%, 0.2)" + } > - + Subscription @@ -404,14 +460,19 @@ export const CheckoutDialog = () => { $alignItems="center" $padding="0.25rem 0.5rem" $flex="1" - $backgroundColor={ - planPeriod === "month" - ? darken(settings.theme.card.background, 8) - : lighten(settings.theme.card.background, 2) - } + {...(planPeriod === "month" && { + $backgroundColor: isLightBackground + ? "hsla(0, 0%, 0%, 0.075)" + : "hsla(0, 0%, 100%, 0.15)", + })} $borderRadius="2.5rem" > - + Billed monthly @@ -421,14 +482,19 @@ export const CheckoutDialog = () => { $alignItems="center" $padding="0.25rem 0.5rem" $flex="1" - $backgroundColor={ - planPeriod === "year" - ? darken(settings.theme.card.background, 8) - : lighten(settings.theme.card.background, 2) - } + {...(planPeriod === "year" && { + $backgroundColor: isLightBackground + ? "hsla(0, 0%, 0%, 0.075)" + : "hsla(0, 0%, 100%, 0.15)", + })} $borderRadius="2.5rem" > - + Billed yearly @@ -436,7 +502,12 @@ export const CheckoutDialog = () => { {savingsPercentage > 0 && ( - + {planPeriod === "month" ? `Save up to ${savingsPercentage}% with yearly billing` : `You are saving ${savingsPercentage}% with yearly billing`} @@ -452,36 +523,56 @@ export const CheckoutDialog = () => { $height="auto" $padding="1.5rem" $flex="1" - $borderBottom="1px solid #DEDEDE" + $borderBottomWidth="1px" + $borderStyle="solid" + $borderColor={ + isLightBackground + ? "hsla(0, 0%, 0%, 0.1)" + : "hsla(0, 0%, 100%, 0.2)" + } > - - + + Plan - + {currentPlan && ( - - - {currentPlan.name} + + + + {currentPlan.name} + {typeof currentPlan.planPrice === "number" && currentPlan.planPeriod && ( - - {formatCurrency(currentPlan.planPrice)}/ - {currentPlan.planPeriod} + + + {formatCurrency(currentPlan.planPrice)}/ + {currentPlan.planPeriod} + )} @@ -504,28 +595,33 @@ export const CheckoutDialog = () => { /> - - - {selectedPlan.name} + + + + {selectedPlan.name} + - - {formatCurrency( - (planPeriod === "month" - ? selectedPlan.monthlyPrice - : selectedPlan.yearlyPrice - )?.price ?? 0, - )} - /{planPeriod} + + + {formatCurrency( + (planPeriod === "month" + ? selectedPlan.monthlyPrice + : selectedPlan.yearlyPrice + )?.price ?? 0, + )} + /{planPeriod} + @@ -535,43 +631,55 @@ export const CheckoutDialog = () => { {selectedPlan && ( - - - {planPeriod === "month" ? "Monthly" : "Yearly"} total:{" "} + + + + {planPeriod === "month" ? "Monthly" : "Yearly"} total:{" "} + - - {formatCurrency( - (planPeriod === "month" - ? selectedPlan.monthlyPrice - : selectedPlan.yearlyPrice - )?.price ?? 0, - )} - /{planPeriod} + + + + {formatCurrency( + (planPeriod === "month" + ? selectedPlan.monthlyPrice + : selectedPlan.yearlyPrice + )?.price ?? 0, + )} + /{planPeriod} + )} + {checkoutStage === "plan" ? ( { - setCheckoutStage("checkout"); - }} + {...(selectedPlan && { + onClick: () => setCheckoutStage("checkout"), + })} $size="sm" > Next: Checkout @@ -607,12 +715,13 @@ export const CheckoutDialog = () => { paymentMethodId: paymentMethodId, }, }); + // throw new Error("Test error."); setIsCheckoutComplete(true); - } catch (error) { - // TODO: handle checkout error - console.error(error); + } catch { + setError( + "Error processing payment. Please try a different payment method.", + ); } finally { - setIsCheckoutComplete(true); setIsLoading(false); } }} @@ -622,9 +731,29 @@ export const CheckoutDialog = () => { )} - - Discounts & credits applied at checkout + + + Discounts & credits applied at checkout + + + {error && ( + + + {error} + + + )} diff --git a/react/src/components/elements/plan-manager/PaymentForm.tsx b/react/src/components/elements/plan-manager/PaymentForm.tsx index 6457ac50..e562d780 100644 --- a/react/src/components/elements/plan-manager/PaymentForm.tsx +++ b/react/src/components/elements/plan-manager/PaymentForm.tsx @@ -38,6 +38,7 @@ export const PaymentForm = ({ plan, period, onConfirm }: PaymentFormProps) => { setIsLoading(true); setIsConfirmed(false); + setMessage(null); try { const { setupIntent, error } = await stripe.confirmSetup({ @@ -58,15 +59,9 @@ export const PaymentForm = ({ plan, period, onConfirm }: PaymentFormProps) => { if (error?.type === "card_error" || error?.type === "validation_error") { setMessage(error.message as string); } - - setIsLoading(false); } catch (error) { - if (error instanceof Error) { - setMessage(error.message); - } else { - setMessage("An unexpected error occured."); - } - + setMessage("A problem occurred while saving your payment method."); + } finally { setIsLoading(false); } }; @@ -89,8 +84,8 @@ export const PaymentForm = ({ plan, period, onConfirm }: PaymentFormProps) => { { /> - + - {message && {message}} - + -
- - - {!isLoading ? "Loading" : "Save payment method"} + + + {isLoading ? "Loading" : "Save payment method"} + + + + {message && ( + + + {message} - -
+
+ )} ); }; diff --git a/react/src/components/elements/plan-manager/PlanManager.tsx b/react/src/components/elements/plan-manager/PlanManager.tsx index e51858ce..fb33e9ef 100644 --- a/react/src/components/elements/plan-manager/PlanManager.tsx +++ b/react/src/components/elements/plan-manager/PlanManager.tsx @@ -1,5 +1,6 @@ import { forwardRef, useMemo } from "react"; import { createPortal } from "react-dom"; +import { useTheme } from "styled-components"; import { useEmbed } from "../../../hooks"; import { type FontStyle } from "../../../context"; import type { RecursivePartial, ElementProps } from "../../../types"; @@ -60,7 +61,7 @@ const resolveDesignProps = ( }, callToAction: { isVisible: props.callToAction?.isVisible ?? true, - buttonSize: props.callToAction?.buttonSize ?? "md", + buttonSize: props.callToAction?.buttonSize ?? "lg", buttonStyle: props.callToAction?.buttonStyle ?? "primary", }, }; @@ -78,7 +79,8 @@ export const PlanManager = forwardRef< >(({ children, className, portal, ...rest }, ref) => { const props = resolveDesignProps(rest); - const { data, settings, layout, stripe, setLayout } = useEmbed(); + const theme = useTheme(); + const { data, layout, stripe, setLayout } = useEmbed(); const { currentPlan, canChangePlan } = useMemo(() => { return { @@ -105,21 +107,15 @@ export const PlanManager = forwardRef< {currentPlan.name} @@ -130,24 +126,19 @@ export const PlanManager = forwardRef< currentPlan.description && ( {currentPlan.description} @@ -160,21 +151,15 @@ export const PlanManager = forwardRef< currentPlan.planPeriod && ( {formatCurrency(currentPlan.planPrice)}/ {currentPlan.planPeriod} @@ -192,13 +177,7 @@ export const PlanManager = forwardRef< $size={props.callToAction.buttonSize} $color={props.callToAction.buttonStyle} > - - Change Plan - + Change Plan )} diff --git a/react/src/components/elements/plan-manager/styles.ts b/react/src/components/elements/plan-manager/styles.ts index b8eb25f5..5bea1812 100644 --- a/react/src/components/elements/plan-manager/styles.ts +++ b/react/src/components/elements/plan-manager/styles.ts @@ -1,4 +1,5 @@ import styled, { css } from "styled-components"; +import { TEXT_BASE_SIZE } from "../../../const"; import { hexToHSL, hslToHex, lighten, darken } from "../../../utils"; import { Button, Text } from "../../ui"; @@ -42,7 +43,7 @@ export const StyledButton = styled(Button)<{ if (disabled) { const { l } = hexToHSL(theme.card.background); color = hslToHex({ h: 0, s: 0, l }); - color = l > 50 ? darken(color, 7.5) : lighten(color, 7.5); + color = l > 50 ? darken(color, 0.075) : lighten(color, 0.15); } return $variant === "filled" @@ -68,8 +69,9 @@ export const StyledButton = styled(Button)<{ &:not(:disabled):hover { ${({ $color = "primary", theme, $variant = "filled" }) => { const specified = theme[$color]; - const lightened = lighten(specified, 15); - const color = specified === lightened ? darken(specified, 15) : lightened; + const lightened = lighten(specified, 0.15); + const color = + specified === lightened ? darken(specified, 0.15) : lightened; const { l } = hexToHSL(theme[$color]); const textColor = l > 50 ? "#000000" : "#FFFFFF"; @@ -95,21 +97,21 @@ export const StyledButton = styled(Button)<{ switch ($size) { case "sm": return css` - font-size: ${15 / 16}rem; - padding: ${12 / 16}rem 0; - border-radius: ${6 / 16}rem; + font-size: ${15 / TEXT_BASE_SIZE}rem; + padding: ${12 / TEXT_BASE_SIZE}rem 0; + border-radius: ${6 / TEXT_BASE_SIZE}rem; `; case "md": return css` - font-size: ${17 / 16}rem; - padding: ${16 / 16}rem 0; - border-radius: ${8 / 16}rem; + font-size: ${17 / TEXT_BASE_SIZE}rem; + padding: ${16 / TEXT_BASE_SIZE}rem 0; + border-radius: ${8 / TEXT_BASE_SIZE}rem; `; case "lg": return css` - font-size: ${19 / 16}rem; - padding: ${20 / 16}rem 0; - border-radius: ${10 / 16}rem; + font-size: ${19 / TEXT_BASE_SIZE}rem; + padding: ${20 / TEXT_BASE_SIZE}rem 0; + border-radius: ${10 / TEXT_BASE_SIZE}rem; `; } }} diff --git a/react/src/components/elements/upcoming-bill/UpcomingBill.tsx b/react/src/components/elements/upcoming-bill/UpcomingBill.tsx index 7c562617..90c23d2b 100644 --- a/react/src/components/elements/upcoming-bill/UpcomingBill.tsx +++ b/react/src/components/elements/upcoming-bill/UpcomingBill.tsx @@ -1,4 +1,5 @@ import { forwardRef, useMemo } from "react"; +import { useTheme } from "styled-components"; import { useEmbed } from "../../../hooks"; import { type FontStyle } from "../../../context"; import type { RecursivePartial, ElementProps } from "../../../types"; @@ -51,7 +52,8 @@ export const UpcomingBill = forwardRef< >(({ className, ...rest }, ref) => { const props = resolveDesignProps(rest); - const { data, settings, stripe } = useEmbed(); + const theme = useTheme(); + const { data, stripe } = useEmbed(); const { upcomingInvoice } = useMemo(() => { return { @@ -69,7 +71,7 @@ export const UpcomingBill = forwardRef< }; }, [data.subscription, data.upcomingInvoice]); - if (!stripe || !data.upcomingInvoice) { + if (!stripe || !upcomingInvoice.amountDue || !upcomingInvoice.dueDate) { return null; } @@ -82,12 +84,10 @@ export const UpcomingBill = forwardRef< $margin="0 0 0.75rem" > {props.header.prefix} {upcomingInvoice.dueDate} @@ -99,16 +99,10 @@ export const UpcomingBill = forwardRef< {props.price.isVisible && ( {formatCurrency(upcomingInvoice.amountDue)} @@ -119,20 +113,13 @@ export const UpcomingBill = forwardRef< Estimated monthly bill. diff --git a/react/src/components/embed/ComponentTree.tsx b/react/src/components/embed/ComponentTree.tsx index b08af4dd..e1af4245 100644 --- a/react/src/components/embed/ComponentTree.tsx +++ b/react/src/components/embed/ComponentTree.tsx @@ -66,13 +66,13 @@ export const ComponentTree = () => { setChildren(nodes.map(renderer)); }, [nodes]); - if (Children.count(children) === 0) { - return ; - } - if (error) { return ; } + if (Children.count(children) === 0) { + return ; + } + return <>{children}; }; diff --git a/react/src/components/layout/card/Card.tsx b/react/src/components/layout/card/Card.tsx index 4f94b8d1..ce9f3dc4 100644 --- a/react/src/components/layout/card/Card.tsx +++ b/react/src/components/layout/card/Card.tsx @@ -1,5 +1,5 @@ import { forwardRef } from "react"; -import { useEmbed } from "../../../hooks"; +import { useTheme } from "styled-components"; import { StyledCard } from "./styles"; export interface CardProps { @@ -17,16 +17,16 @@ export const Card = forwardRef( return acc; }, {}); */ - const { settings } = useEmbed(); + const theme = useTheme(); return ( {children} diff --git a/react/src/components/layout/card/styles.ts b/react/src/components/layout/card/styles.ts index 2ecb7765..945ff157 100644 --- a/react/src/components/layout/card/styles.ts +++ b/react/src/components/layout/card/styles.ts @@ -1,6 +1,6 @@ import styled, { css } from "styled-components"; import { TEXT_BASE_SIZE } from "../../../const"; -import { darken, lighten, hexToHSL } from "../../../utils"; +import { hexToHSL } from "../../../utils"; export const StyledCard = styled.div<{ $sectionLayout?: "merged" | "separate"; @@ -35,9 +35,7 @@ export const StyledCard = styled.div<{ ${() => { const { l } = hexToHSL(theme.card.background); const borderColor = - l > 50 - ? darken(theme.card.background, 10) - : lighten(theme.card.background, 20); + l > 50 ? "hsla(0, 0%, 0%, 0.1)" : "hsla(0, 0%, 100%, 0.2)"; const borderRadius = `${$borderRadius / TEXT_BASE_SIZE}rem`; const boxShadow = "0px 1px 20px 0px #1018280F, 0px 1px 3px 0px #1018281A"; diff --git a/react/src/components/layout/viewport/Viewport.tsx b/react/src/components/layout/viewport/Viewport.tsx index 830edda9..891e5d08 100644 --- a/react/src/components/layout/viewport/Viewport.tsx +++ b/react/src/components/layout/viewport/Viewport.tsx @@ -1,4 +1,6 @@ import { forwardRef } from "react"; +import { useTheme } from "styled-components"; +import { TEXT_BASE_SIZE } from "../../../const"; import { useEmbed } from "../../../hooks"; import { StyledViewport } from "./styles"; import { Box, Flex } from "../../ui"; @@ -7,41 +9,42 @@ export interface ViewportProps extends React.HTMLProps {} export const Viewport = forwardRef( ({ children, ...props }, ref) => { - const { settings, layout } = useEmbed(); + const theme = useTheme(); + const { layout } = useEmbed(); return ( {layout === "disabled" ? ( Coming soon The plan manager will be back very soon. diff --git a/react/src/components/ui/icon/IconRound.tsx b/react/src/components/ui/icon/IconRound.tsx index b4c47046..6bb9f8db 100644 --- a/react/src/components/ui/icon/IconRound.tsx +++ b/react/src/components/ui/icon/IconRound.tsx @@ -4,7 +4,7 @@ import { Container } from "./styles"; export interface IconRoundProps extends React.HTMLAttributes { name: IconNameTypes; variant?: "outline" | "filled"; - size?: "tn" | "sm" | "md" | "lg"; + size?: "tn" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl"; colors?: [string, string]; } diff --git a/react/src/components/ui/icon/styles.ts b/react/src/components/ui/icon/styles.ts index a9bb0baa..ab15e5aa 100644 --- a/react/src/components/ui/icon/styles.ts +++ b/react/src/components/ui/icon/styles.ts @@ -8,7 +8,7 @@ export const Icon = styled.i` `; export const Container = styled.div<{ - $size: "tn" | "sm" | "md" | "lg"; + $size: "tn" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl"; $variant: "outline" | "filled"; $colors: [string, string]; }>` @@ -17,6 +17,7 @@ export const Container = styled.div<{ align-items: center; flex-shrink: 0; border-radius: 9999px; + ${({ $size }) => { const base = 24; let scale = 1.0; @@ -35,6 +36,15 @@ export const Container = styled.div<{ case "lg": scale *= 1.75; break; + case "xl": + scale *= 2; + break; + case "2xl": + scale *= 2.5; + break; + case "3xl": + scale *= 3; + break; } return css` @@ -44,14 +54,21 @@ export const Container = styled.div<{ height: ${((base + 8) * scale) / TEXT_BASE_SIZE}rem; `; }} + ${({ $variant, $colors }) => $variant === "outline" ? css` - color: ${$colors[0]}; background-color: transparent; + + ${Icon} { + color: ${$colors[0]}; + } ` : css` - color: ${$colors[0]}; background-color: ${$colors[1]}; + + ${Icon} { + color: ${$colors[0]}; + } `} `; diff --git a/react/src/components/ui/modal/Modal.tsx b/react/src/components/ui/modal/Modal.tsx index 35f7ec1c..2eec4da7 100644 --- a/react/src/components/ui/modal/Modal.tsx +++ b/react/src/components/ui/modal/Modal.tsx @@ -1,21 +1,25 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useTheme } from "styled-components"; import { useEmbed } from "../../../hooks"; -import { lighten, darken, hexToHSL } from "../../../utils"; +import { hexToHSL } from "../../../utils"; import { Box, Flex } from "../"; interface ModalProps { children: React.ReactNode; - size?: "md" | "lg"; + size?: "sm" | "md" | "lg" | "auto"; onClose?: () => void; } -export const Modal = ({ children, onClose }: ModalProps) => { +export const Modal = ({ children, size = "auto", onClose }: ModalProps) => { const theme = useTheme(); const { setLayout } = useEmbed(); const ref = useRef(null); + const isLightBackground = useMemo(() => { + return hexToHSL(theme.card.background).l > 50; + }, [theme.card.background]); + const handleClose = useCallback(() => { setLayout("portal"); onClose?.(); @@ -47,9 +51,7 @@ export const Modal = ({ children, onClose }: ModalProps) => { $width="100%" $height="100%" $backgroundColor={ - hexToHSL(theme.card.background).l > 50 - ? darken(theme.card.background, 15) - : lighten(theme.card.background, 15) + isLightBackground ? "hsl(0, 0%, 85%)" : "hsl(0, 0%, 15%)" } $overflow="hidden" > @@ -60,13 +62,15 @@ export const Modal = ({ children, onClose }: ModalProps) => { $transform="translate(-50%, -50%)" $flexDirection="column" $overflow="hidden" - $width="calc(100% - 5rem)" - $height="calc(100% - 5rem)" - $backgroundColor={ - hexToHSL(theme.card.background).l > 50 - ? darken(theme.card.background, 2.5) - : lighten(theme.card.background, 2.5) - } + {...(size === "auto" + ? { $width: "min-content", $height: "min-content" } + : { + $width: "100%", + $height: "100%", + $maxWidth: "1366px", + $maxHeight: "768px", + })} + $backgroundColor={theme.card.background} $borderRadius="0.5rem" $boxShadow="0px 1px 20px 0px #1018280F, 0px 1px 3px 0px #1018281A;" id="select-plan-dialog" diff --git a/react/src/components/ui/modal/ModalHeader.tsx b/react/src/components/ui/modal/ModalHeader.tsx index 5f0476c8..8070a177 100644 --- a/react/src/components/ui/modal/ModalHeader.tsx +++ b/react/src/components/ui/modal/ModalHeader.tsx @@ -1,18 +1,27 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useTheme } from "styled-components"; import { useEmbed } from "../../../hooks"; -import { lighten, darken, hexToHSL } from "../../../utils"; +import { hexToHSL } from "../../../utils"; import { Box, Flex, Icon } from "../"; interface ModalHeaderProps { children: React.ReactNode; + bordered?: boolean; onClose?: () => void; } -export const ModalHeader = ({ children, onClose }: ModalHeaderProps) => { +export const ModalHeader = ({ + children, + bordered = false, + onClose, +}: ModalHeaderProps) => { const theme = useTheme(); const { setLayout } = useEmbed(); + const isLightBackground = useMemo(() => { + return hexToHSL(theme.card.background).l > 50; + }, [theme.card.background]); + const handleClose = useCallback(() => { setLayout("portal"); onClose?.(); @@ -22,17 +31,17 @@ export const ModalHeader = ({ children, onClose }: ModalHeaderProps) => { 50 - ? darken(theme.card.background, 15) - : lighten(theme.card.background, 15) - } + $height="5rem" + $padding="0 1.5rem 0 3rem" + {...(bordered && { + $borderBottomWidth: "1px", + $borderBottomStyle: "solid", + $borderBottomColor: isLightBackground + ? "hsla(0, 0%, 0%, 0.15)" + : "hsla(0, 0%, 100%, 0.15)", + })} > {children} @@ -41,10 +50,9 @@ export const ModalHeader = ({ children, onClose }: ModalHeaderProps) => { name="close" style={{ fontSize: 36, - color: - hexToHSL(theme.card.background).l > 50 - ? darken(theme.card.background, 27.5) - : lighten(theme.card.background, 27.5), + color: isLightBackground + ? "hsla(0, 0%, 0%, 0.275)" + : "hsla(0, 0%, 100%, 0.275)", }} /> diff --git a/react/src/components/ui/progress-bar/ProgressBar.tsx b/react/src/components/ui/progress-bar/ProgressBar.tsx index b0d58ac2..f68ccdcf 100644 --- a/react/src/components/ui/progress-bar/ProgressBar.tsx +++ b/react/src/components/ui/progress-bar/ProgressBar.tsx @@ -44,13 +44,13 @@ export const ProgressBar = ({ $overflow="hidden" $width="100%" $height={`${8 / TEXT_BASE_SIZE}rem`} - $background="#F2F4F7" + $backgroundColor="#F2F4F7" $borderRadius="9999px" > @@ -65,7 +65,7 @@ export const ProgressBar = ({ > diff --git a/react/src/context/embed.tsx b/react/src/context/embed.tsx index f22758c7..a2d0591f 100644 --- a/react/src/context/embed.tsx +++ b/react/src/context/embed.tsx @@ -461,38 +461,6 @@ function parseEditorState(data: SerializedEditorState) { return arr; } -async function fetchComponent(id: string, api: CheckoutApi) { - const nodes: SerializedNodeWithChildren[] = []; - const settings: EmbedSettings = { ...defaultSettings }; - - const response = await api.hydrateComponent({ componentId: id }); - const { data } = response; - - if (data.component?.ast) { - const compressed = data.component.ast; - const json = inflate(Uint8Array.from(Object.values(compressed)), { - to: "string", - }); - const ast = getEditorState(json); - if (ast) { - merge(settings, ast.ROOT.props.settings); - nodes.push(...parseEditorState(ast)); - } - } - - let stripe: Promise | null = null; - if (data.stripeEmbed?.publishableKey) { - stripe = loadStripe(data.stripeEmbed.publishableKey); - } - - return { - data, - nodes, - settings, - stripe, - }; -} - export type EmbedLayout = "portal" | "checkout" | "payment" | "disabled"; export interface EmbedContextProps { @@ -503,6 +471,8 @@ export interface EmbedContextProps { stripe: Promise | null; layout: EmbedLayout; error?: Error; + isPending: boolean; + hydrate: () => void; setData: (data: RecursivePartial) => void; updateSettings: (settings: RecursivePartial) => void; setStripe: (stripe: Promise | null) => void; @@ -519,6 +489,8 @@ export const EmbedContext = createContext({ stripe: null, layout: "portal", error: undefined, + isPending: false, + hydrate: () => {}, setData: () => {}, updateSettings: () => {}, setStripe: () => {}, @@ -547,7 +519,9 @@ export const EmbedProvider = ({ settings: EmbedSettings; stripe: Promise | null; layout: EmbedLayout; + isPending: boolean; error: Error | undefined; + hydrate: () => void; setData: (data: RecursivePartial) => void; updateSettings: (settings: RecursivePartial) => void; setStripe: (stripe: Promise | null) => void; @@ -562,7 +536,9 @@ export const EmbedProvider = ({ settings: { ...defaultSettings }, stripe: null, layout: "portal", + isPending: false, error: undefined, + hydrate: () => {}, setData: () => {}, updateSettings: () => {}, setStripe: () => {}, @@ -570,57 +546,56 @@ export const EmbedProvider = ({ }; }); - useEffect(() => { - const element = document.getElementById("schematic-fonts"); - if (element) { - return void (styleRef.current = element as HTMLLinkElement); - } + const hydrate = useCallback(async () => { + setState((prev) => ({ ...prev, isPending: true, error: undefined })); - const style = document.createElement("link"); - style.id = "schematic-fonts"; - style.rel = "stylesheet"; - document.head.appendChild(style); - styleRef.current = style; - }, []); + try { + const nodes: SerializedNodeWithChildren[] = []; + const settings: EmbedSettings = { ...defaultSettings }; - useEffect(() => { - if (!accessToken) { - return; - } - - const config = new Configuration({ ...apiConfig, apiKey: accessToken }); - const api = new CheckoutApi(config); - setState((prev) => ({ ...prev, api })); - }, [accessToken, apiConfig]); - - useEffect(() => { - if (!id || !state.api) { - return; - } - - setState((prev) => ({ ...prev, error: undefined })); - fetchComponent(id, state.api) - .then(async (resolvedData) => { - setState((prev) => ({ ...prev, ...resolvedData })); - }) - .catch((error) => setState((prev) => ({ ...prev, error }))); - }, [id, state.api]); + if (!id || !state.api) { + throw new Error("Invalid component id or api instance."); + } - useEffect(() => { - const fontSet = new Set([]); - Object.values(state.settings.theme.typography).forEach(({ fontFamily }) => { - fontSet.add(fontFamily); - }); + const response = await state.api.hydrateComponent({ componentId: id }); + const { data } = response; + + if (data.component?.ast) { + const compressed = data.component.ast; + const json = inflate(Uint8Array.from(Object.values(compressed)), { + to: "string", + }); + const ast = getEditorState(json); + if (ast) { + merge(settings, ast.ROOT.props.settings); + nodes.push(...parseEditorState(ast)); + } + } - if (fontSet.size > 0) { - const src = `https://fonts.googleapis.com/css2?${[...fontSet] - .map((fontFamily) => `family=${fontFamily}&display=swap`) - .join("&")}`; - if (styleRef.current) { - styleRef.current.href = src; + let stripe: Promise | null = null; + if (data.stripeEmbed?.publishableKey) { + stripe = loadStripe(data.stripeEmbed.publishableKey); } + + setState((prev) => ({ + ...prev, + data, + nodes, + settings, + stripe, + isPending: false, + })); + } catch (error) { + setState((prev) => ({ + ...prev, + isPending: false, + error: + error instanceof Error + ? error + : new Error("An unknown error occurred."), + })); } - }, [state.settings.theme.typography]); + }, [id, state.api]); const setData = useCallback( (data: RecursivePartial) => { @@ -668,6 +643,49 @@ export const EmbedProvider = ({ [setState], ); + useEffect(() => { + const element = document.getElementById("schematic-fonts"); + if (element) { + return void (styleRef.current = element as HTMLLinkElement); + } + + const style = document.createElement("link"); + style.id = "schematic-fonts"; + style.rel = "stylesheet"; + document.head.appendChild(style); + styleRef.current = style; + }, []); + + useEffect(() => { + if (!accessToken) { + return; + } + + const config = new Configuration({ ...apiConfig, apiKey: accessToken }); + const api = new CheckoutApi(config); + setState((prev) => ({ ...prev, api })); + }, [accessToken, apiConfig]); + + useEffect(() => { + hydrate(); + }, [hydrate]); + + useEffect(() => { + const fontSet = new Set([]); + Object.values(state.settings.theme.typography).forEach(({ fontFamily }) => { + fontSet.add(fontFamily); + }); + + if (fontSet.size > 0) { + const src = `https://fonts.googleapis.com/css2?${[...fontSet] + .map((fontFamily) => `family=${fontFamily}&display=swap`) + .join("&")}`; + if (styleRef.current) { + styleRef.current.href = src; + } + } + }, [state.settings.theme.typography]); + const renderChildren = () => { if (state.stripe) { return ( @@ -724,6 +742,8 @@ export const EmbedProvider = ({ stripe: state.stripe, layout: state.layout, error: state.error, + isPending: state.isPending, + hydrate, setData, updateSettings, setStripe, diff --git a/react/src/context/schematic.tsx b/react/src/context/schematic.tsx index 21011355..a502f659 100644 --- a/react/src/context/schematic.tsx +++ b/react/src/context/schematic.tsx @@ -1,5 +1,5 @@ import * as SchematicJS from "@schematichq/schematic-js"; -import { createContext, useEffect, useMemo, useState } from "react"; +import React, { createContext, useEffect, useMemo } from "react"; type BaseSchematicProviderProps = Omit< SchematicJS.SchematicOptions, @@ -23,13 +23,12 @@ export type SchematicProviderProps = | SchematicProviderPropsWithPublishableKey; export interface SchematicContextProps { - client?: SchematicJS.Schematic; - flagValues: Record; + client: SchematicJS.Schematic; } -export const SchematicContext = createContext({ - flagValues: {}, -}); +export const SchematicContext = createContext( + null, +); export const SchematicProvider: React.FC = ({ children, @@ -37,50 +36,37 @@ export const SchematicProvider: React.FC = ({ publishableKey, ...clientOpts }) => { - const [client, setClient] = useState(); - const [flagValues, setFlagValues] = useState>({}); - const memoizedClientOpts = useMemo( - () => clientOpts, - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - [JSON.stringify(clientOpts)], - ); - const { useWebSocket = true } = clientOpts; + const client = useMemo(() => { + const { useWebSocket = true } = clientOpts; + if (providedClient) { + return providedClient; + } + return new SchematicJS.Schematic(publishableKey!, { + useWebSocket, + ...clientOpts, + }); + }, [providedClient, publishableKey, clientOpts]); useEffect(() => { - let cleanupFunction: (() => void) | undefined; - - // If a client was explicitly provided, always use this - if (providedClient) { - setClient(providedClient); - cleanupFunction = () => { - providedClient.cleanup().catch((error) => { + // Clean up Schematic client (i.e., close websocket connection) when the + // component is unmounted + return () => { + // If the client was provided as an option, we don't need to clean it up; + // assume whoever provided it will clean it up + if (!providedClient) { + client.cleanup().catch((error) => { console.error("Error during cleanup:", error); }); - }; - } else { - // Otherwise, if a publishable key was provided, create a new client - // with the client options - const newClient = new SchematicJS.Schematic(publishableKey, { - ...memoizedClientOpts, - flagListener: setFlagValues, - useWebSocket, - }); - setClient(newClient); - cleanupFunction = () => { - newClient.cleanup().catch((error) => { - console.error("Error during cleanup:", error); - }); - }; - } - - // Return the cleanup function - return cleanupFunction; - }, [memoizedClientOpts, providedClient, publishableKey, useWebSocket]); + } + }; + }, [client, providedClient]); - const contextValue: SchematicContextProps = { - client, - flagValues, - }; + const contextValue = useMemo( + () => ({ + client, + }), + [client], + ); return ( @@ -88,3 +74,11 @@ export const SchematicProvider: React.FC = ({ ); }; + +export const useSchematic = () => { + const context = React.useContext(SchematicContext); + if (context === null) { + throw new Error("useSchematic must be used within a SchematicProvider"); + } + return context; +}; diff --git a/react/src/hooks/schematic.ts b/react/src/hooks/schematic.ts index 52d0b0d9..f22a8468 100644 --- a/react/src/hooks/schematic.ts +++ b/react/src/hooks/schematic.ts @@ -1,6 +1,6 @@ import * as SchematicJS from "@schematichq/schematic-js"; -import { useContext, useEffect, useState } from "react"; -import { SchematicContext } from "../context"; +import { useMemo, useSyncExternalStore, useCallback } from "react"; +import { useSchematic } from "../context"; export interface SchematicFlags { [key: string]: boolean; @@ -14,50 +14,70 @@ export type UseSchematicFlagOpts = SchematicHookOpts & { fallback?: boolean; }; -export const useSchematic = () => useContext(SchematicContext); - export const useSchematicClient = (opts?: SchematicHookOpts) => { const schematic = useSchematic(); const { client } = opts ?? {}; - if (client) { - return client; - } - - return schematic.client; + return useMemo(() => { + if (client) { + return client; + } + return schematic.client; + }, [client, schematic.client]); }; export const useSchematicContext = (opts?: SchematicHookOpts) => { const client = useSchematicClient(opts); - const { setContext } = client ?? {}; - - return { setContext }; + return useMemo( + () => ({ + setContext: client.setContext.bind(client), + }), + [client], + ); }; export const useSchematicEvents = (opts?: SchematicHookOpts) => { const client = useSchematicClient(opts); - const { track, identify } = client ?? {}; - return { track, identify }; + const track = useCallback( + (...args: Parameters) => client.track(...args), + [client], + ); + + const identify = useCallback( + (...args: Parameters) => client.identify(...args), + [client], + ); + + return useMemo(() => ({ track, identify }), [track, identify]); }; export const useSchematicFlag = (key: string, opts?: UseSchematicFlagOpts) => { - const { flagValues } = useSchematic(); - const { client } = opts ?? {}; - const { fallback = false } = opts ?? {}; - - const [value, setValue] = useState(fallback); - const flagValue = flagValues[key]; - - useEffect(() => { - if (typeof flagValue !== "undefined") { - setValue(flagValue); - } else if (client) { - client.checkFlag({ key, fallback }).then(setValue); - } else { - setValue(fallback); - } - }, [key, fallback, flagValue, client]); + const client = useSchematicClient(opts); + const fallback = opts?.fallback ?? false; + + const subscribe = useCallback( + (callback: () => void) => client.addFlagValueListener(key, callback), + [client, key], + ); + + const getSnapshot = useCallback(() => { + const value = client.getFlagValue(key); + return typeof value === "undefined" ? fallback : value; + }, [client, key, fallback]); + + return useSyncExternalStore(subscribe, getSnapshot); +}; + +export const useSchematicIsPending = (opts?: SchematicHookOpts) => { + const client = useSchematicClient(opts); + + const subscribe = useCallback( + (callback: () => void) => client.addIsPendingListener(callback), + [client], + ); + + const getSnapshot = useCallback(() => client.getIsPending(), [client]); - return value; + return useSyncExternalStore(subscribe, getSnapshot); }; diff --git a/react/src/index.ts b/react/src/index.ts index bcfc0060..588bf8c5 100644 --- a/react/src/index.ts +++ b/react/src/index.ts @@ -1,6 +1,7 @@ import { defaultSettings, defaultTheme, + useSchematic, EmbedContext, EmbedProvider, SchematicProvider, @@ -10,10 +11,10 @@ import { } from "./context"; import { useEmbed, - useSchematic, useSchematicContext, useSchematicEvents, useSchematicFlag, + useSchematicIsPending, type SchematicHookOpts, type UseSchematicFlagOpts, } from "./hooks"; @@ -28,6 +29,7 @@ export { useSchematicContext, useSchematicEvents, useSchematicFlag, + useSchematicIsPending, EmbedContext, EmbedProvider, SchematicProvider, diff --git a/react/src/utils/color.ts b/react/src/utils/color.ts index a770970e..0d45f939 100644 --- a/react/src/utils/color.ts +++ b/react/src/utils/color.ts @@ -93,7 +93,7 @@ export function hslToHex({ h, s, l }: { h: number; s: number; l: number }) { export function adjustLightness(color: string, amount: number) { const { h, s, l } = hexToHSL(color); - return hslToHex({ h, s, l: Math.max(Math.min(l + amount, 100), 0) }); + return hslToHex({ h, s, l: Math.max(Math.min(l + amount * 100, 100), 0) }); } export function lighten(color: string, amount: number) { @@ -104,6 +104,11 @@ export function darken(color: string, amount: number) { return adjustLightness(color, -amount); } +export function hsla(color: string, amount: number) { + const { h, s, l } = hexToHSL(color); + return `hsla(${h}, ${s}%, ${l}%, ${amount})`; +} + export function invert(color: string) { const hex = color.replace("#", ""); return `#${(Number(`0x1${hex}`) ^ 0xffffff).toString(16).slice(1).toUpperCase()}`;