Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nasty-maps-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@godaddy/react": patch
---

Add optional appearance to checkout session
4 changes: 2 additions & 2 deletions packages/react/src/components/checkout/checkout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,8 @@ export function Checkout(props: CheckoutProps) {
>(undefined);
const { t } = useGoDaddyContext();

useTheme();
useVariables(props?.appearance?.variables);
useTheme(session?.appearance?.theme);
useVariables(session?.appearance?.variables || props?.appearance?.variables);

const formSchema = React.useMemo(() => {
const extendedSchema = checkoutFormSchema
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ export function DraftOrderExpressCheckout(props: ExpressCheckoutProps) {
paypalConfig,
} = props;

useTheme();
useVariables(props?.appearance?.variables);
useTheme(session?.appearance?.theme);
useVariables(session?.appearance?.variables || props?.appearance?.variables);

const [isConfirmingCheckout, setIsConfirmingCheckout] = React.useState(false);
const [checkoutErrors, setCheckoutErrors] = React.useState<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@ export function ExpressCheckoutButton() {
buttonOptions: {
type: 'plain',
margin: '0',
height: '48px',
height: '50px',
width: '100%',
justifyContent: 'flex-start',
onClick: handleExpressPayClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ function PayPalButtonsWrapper() {
};

if (isPending || !isResolved) {
return <Skeleton className='h-10 w-full' />;
return <Skeleton className='h-13 w-full' />;
}

return (
Expand All @@ -97,7 +97,7 @@ function PayPalButtonsWrapper() {
label: 'pay',
shape: 'rect',
borderRadius: 8,
height: 40,
height: 50,
}}
disabled={isPaypalDisabled || isPaymentDisabled}
onClick={handleClick}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function PazeCheckoutButton() {
buttonOptions: {
type: 'plain',
margin: '0',
height: '48px',
height: '50px',
width: '100%',
justifyContent: 'flex-start',
onClick: handlePazeClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,8 @@ export function PaymentForm(
return (
<Button
type='button'
className='w-full flex items-center justify-center gap-2 px-8 h-10'
size='lg'
className='w-full flex items-center justify-center gap-2 px-8 h-13'
disabled
>
<LoaderCircle className='h-5 w-5 animate-spin' />
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/checkout/totals/totals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export function DraftOrderTotals({
<span className='text-xs text-muted-foreground'>
{currencyCode}{' '}
</span>
<span className='font-medium'>
<span className='font-bold text-lg'>
{new Intl.NumberFormat('en-us', {
style: 'currency',
currency: currencyCode,
Expand Down
57 changes: 57 additions & 0 deletions packages/react/src/components/checkout/utils/case-conversion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CSSVariables } from '@/godaddy-provider';

/**
* Convert kebab-case string to camelCase
* @example kebabToCamel('font-sans') // 'fontSans'
*/
export function kebabToCamel(str: string): string {
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
}

/**
* Convert camelCase string to kebab-case
* @example camelToKebab('fontSans') // 'font-sans'
*/
export function camelToKebab(str: string): string {
return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
}

/**
* Convert kebab-case CSS variables object to camelCase for GraphQL
* @param variables - Object with kebab-case keys
* @returns Object with camelCase keys
* @example
* convertCSSVariablesToCamelCase({ 'font-sans': 'Arial', 'secondary-background': '#fff' })
* // { fontSans: 'Arial', secondaryBackground: '#fff' }
*/
export function convertCSSVariablesToCamelCase(
variables: CSSVariables
): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(variables)) {
if (value !== undefined) {
result[kebabToCamel(key)] = value;
}
}
return result;
}

/**
* Convert camelCase object keys to kebab-case (for GraphQL response to CSS variables)
* @param obj - Object with camelCase keys
* @returns Object with kebab-case keys typed as CSSVariables
* @example
* convertCamelCaseToKebabCase({ fontSans: 'Arial', secondaryBackground: '#fff' })
* // { 'font-sans': 'Arial', 'secondary-background': '#fff' }
*/
export function convertCamelCaseToKebabCase(
obj: Record<string, string>
): CSSVariables {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== undefined) {
result[camelToKebab(key)] = value;
}
}
return result as CSSVariables;
}
2 changes: 1 addition & 1 deletion packages/react/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const buttonVariants = cva(
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
lg: 'h-13 rounded-md px-8',
icon: 'h-9 w-9',
},
},
Expand Down
10 changes: 8 additions & 2 deletions packages/react/src/hooks/use-theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,15 @@ export const themes = {

export type Theme = keyof typeof themes;

export function useTheme() {
/**
* Hook that applies theme from override or context
* @param {Theme} [overrideTheme] - Optional theme that overrides context theme
*/
export function useTheme(overrideTheme?: Theme | null) {
const { appearance } = useGoDaddyContext();
const theme = appearance?.theme;

// Priority: overrideTheme > context.appearance.theme
const theme = overrideTheme ?? appearance?.theme;

useEffect(() => {
// Remove all theme classes
Expand Down
36 changes: 29 additions & 7 deletions packages/react/src/hooks/use-variables.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,35 @@
'use client';
import { useEffect } from 'react';
import { convertCamelCaseToKebabCase } from '@/components/checkout/utils/case-conversion';
// hooks/use-variables.ts
import {
type CSSVariables,
type GoDaddyVariables,
useGoDaddyContext,
} from '@/godaddy-provider';

/**
* Checks if a variables object is already in kebab-case format by checking for hyphens
*/
function isKebabCase(obj: Record<string, unknown>): boolean {
return Object.keys(obj).some(key => key.includes('-'));
}

/**
* Hook that applies CSS variables from the GoDaddy context to the document
* @param {GoDaddyVariables} [overrideVariables] - Optional variables that override context variables
* Priority: overrideVariables > context.appearance
* @param {GoDaddyVariables} [overrideVariables] - Optional variables that override context variables (can be camelCase or kebab-case)
*/
export function useVariables(overrideVariables?: GoDaddyVariables) {
const { appearance } = useGoDaddyContext();

// Context variables are already in kebab-case
const contextVariables = appearance?.variables;

useEffect(() => {
if (!contextVariables && !overrideVariables) return;

// Extract CSS variables from context
// Extract CSS variables from context (lowest priority)
let contextCssVars: CSSVariables | undefined;
if (contextVariables) {
if ('checkout' in contextVariables) {
Expand All @@ -28,17 +39,28 @@ export function useVariables(overrideVariables?: GoDaddyVariables) {
}
}

// Extract CSS variables from overrides
// Extract CSS variables from overrides (highest priority)
let overrideCssVars: CSSVariables | undefined;
if (overrideVariables) {
let rawVars: Record<string, string>;

// Extract the raw variables object
if ('checkout' in overrideVariables) {
overrideCssVars = overrideVariables.checkout;
rawVars = overrideVariables.checkout as Record<string, string>;
} else {
rawVars = overrideVariables as Record<string, string>;
}

// Convert to kebab-case only if NOT already in kebab-case
// (session.appearance.variables are camelCase, props.appearance.variables are kebab-case)
if (isKebabCase(rawVars)) {
overrideCssVars = rawVars as CSSVariables;
} else {
overrideCssVars = overrideVariables as CSSVariables;
overrideCssVars = convertCamelCaseToKebabCase(rawVars);
}
}

// Merge the variables, with overrides taking precedence
// Merge the variables, with priority: override > context
const mergedVars: CSSVariables = {
...contextCssVars,
...overrideCssVars,
Expand All @@ -49,7 +71,7 @@ export function useVariables(overrideVariables?: GoDaddyVariables) {

// Apply the CSS variables to the document
for (const [key, value] of Object.entries(mergedVars)) {
if (value !== undefined) {
if (value != null) {
rootStyle.setProperty(`--gd-${key}`, value.toString());
}
}
Expand Down
38 changes: 36 additions & 2 deletions packages/react/src/lib/godaddy/godaddy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use server';

import { convertCSSVariablesToCamelCase } from '@/components/checkout/utils/case-conversion';
import type { CSSVariables, GoDaddyAppearance } from '@/godaddy-provider';
import type { ResultOf } from '@/gql.tada';
import { graphqlRequestWithErrors } from '@/lib/graphql-with-errors';
import type {
Expand Down Expand Up @@ -38,12 +40,20 @@ import {
DraftOrderTaxesQuery,
} from './queries';

// Type for createCheckoutSession input with kebab-case appearance
export type CreateCheckoutSessionInputWithKebabCase = Omit<
CheckoutSessionInput['input'],
'appearance'
> & {
appearance?: GoDaddyAppearance;
};

function getHostByEnvironment(): string {
return `https://checkout.commerce.${process.env.GODADDY_HOST || process.env.NEXT_PUBLIC_GODADDY_HOST || 'api.godaddy.com'}`;
}

export async function createCheckoutSession(
input: CheckoutSessionInput['input'],
input: CreateCheckoutSessionInputWithKebabCase,
{ accessToken }: { accessToken: string }
): Promise<
ResultOf<typeof CreateCheckoutSessionMutation>['createCheckoutSession']
Expand All @@ -52,13 +62,37 @@ export async function createCheckoutSession(
throw new Error('No public access token provided');
}

// Convert appearance variables from kebab-case to camelCase for GraphQL
let convertedVariables: Record<string, string> | undefined;
if (input.appearance?.variables) {
const variables = input.appearance.variables;
// Check if variables is nested under 'checkout' or is direct CSSVariables
if ('checkout' in variables) {
convertedVariables = convertCSSVariablesToCamelCase(variables.checkout);
} else {
convertedVariables = convertCSSVariablesToCamelCase(variables);
}
}

// Exclude appearance from input and add it back with converted variables
const { appearance, ...restInput } = input;
const graphqlInput: CheckoutSessionInput['input'] = {
...restInput,
...(appearance && {
appearance: {
theme: appearance.theme,
...(convertedVariables && { variables: convertedVariables }),
},
}),
};

const GODADDY_HOST = getHostByEnvironment();
const response = await graphqlRequestWithErrors<
ResultOf<typeof CreateCheckoutSessionMutation>
>(
GODADDY_HOST,
CreateCheckoutSessionMutation,
{ input },
{ input: graphqlInput },
{ Authorization: `Bearer ${accessToken}` }
);

Expand Down
Loading