Merged in 5645-payment_method_page (pull request #65)

5645 payment method page

* wip payment method in new site

* payment method page

new page to add user's payment method (credit card / paypal) alone
with billing address details

* Update recurly/braintree tokens


Approved-by: Seth Call
This commit is contained in:
Nuwan Chaturanga 2025-07-24 03:25:55 +00:00 committed by Seth Call
parent 9058c8af1d
commit c7e80a0694
16 changed files with 639 additions and 9 deletions

View File

@ -12,3 +12,5 @@ REACT_APP_SITE_KEY=6Let8dgSAAAAAFheKGWrs6iaq_hIlPOZ2f3Bb56B
REACT_APP_GOOGLE_ANALYTICS_ID=G-MC9BTWXWY4
PUBLIC_URL=
REACT_APP_COOKIE_DOMAIN=.jamkazam.local
REACT_APP_RECURLY_PUBLIC_API_KEY=ewr1-hvDV1xQxDw0HPaaRFP4KNE
REACT_APP_BRAINTREE_TOKEN=sandbox_pgjp8dvs_5v5rwm94m2vrfbms

View File

@ -9,3 +9,5 @@ REACT_APP_BITBUCKET_COMMIT=dev
REACT_APP_ENV=development
REACT_APP_COOKIE_DOMAIN=.jamkazam.com
REACT_APP_GOOGLE_ANALYTICS_ID=G-MC9BTWXWY4
REACT_APP_RECURLY_PUBLIC_API_KEY=
REACT_APP_BRAINTREE_TOKEN=

View File

@ -9,3 +9,5 @@ REACT_APP_RECAPTCHA_ENABLED=true
REACT_APP_SITE_KEY=6Let8dgSAAAAAFheKGWrs6iaq_hIlPOZ2f3Bb56B
REACT_APP_COOKIE_DOMAIN=.jamkazam.com
REACT_APP_GOOGLE_ANALYTICS_ID=G-SPTNJRW7WB
REACT_APP_RECURLY_PUBLIC_API_KEY=ewr1-hvDV1xQxDw0HPaaRFP4KNE
REACT_APP_BRAINTREE_TOKEN=production_hc7z69yq_pwwc6zm3d478kfrh

View File

@ -9,3 +9,5 @@ REACT_APP_RECAPTCHA_ENABLED=false
REACT_APP_SITE_KEY=6Let8dgSAAAAAFheKGWrs6iaq_hIlPOZ2f3Bb56B
REACT_APP_COOKIE_DOMAIN=.staging.jamkazam.com
REACT_APP_GOOGLE_ANALYTICS_ID=G-8W0GTL53NT
REACT_APP_RECURLY_PUBLIC_API_KEY=ewr1-AjUHUfcLtIsPdtetD4mj2x
REACT_APP_BRAINTREE_TOKEN=sandbox_pgjp8dvs_5v5rwm94m2vrfbms

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -37,6 +37,7 @@ import JKEditProfile from '../page/JKEditProfile';
import JKEditAccount from '../page/JKEditAccount';
import JKAccountSubscription from '../page/JKAccountSubscription';
import JKPaymentHistory from '../page/JKPaymentHistory';
import JKPaymentMethod from '../page/JKPaymentMethod';
import JKAccountPreferences from '../page/JKAccountPreferences';
import JKAffiliateProgram from '../affiliate/JKAffiliateProgram';
@ -276,6 +277,10 @@ function JKDashboardMain() {
}
useScript(`${process.env.REACT_APP_CLIENT_BASE_URL}/client_scripts`, initJKScripts);
useScript('https://js.recurly.com/v4/recurly.js', () => {
console.log('Recurly.js script loaded');
window.recurly.configure(process.env.REACT_APP_RECURLY_PUBLIC_API_KEY);
});
return (
<div className={isFluid || isKanban ? 'container-fluid' : 'container'}>
@ -297,6 +302,7 @@ function JKDashboardMain() {
<PrivateRoute path="/account/identity" component={JKEditAccount} />
<PrivateRoute path="/account/subscription" component={JKAccountSubscription} />
<PrivateRoute path="/account/payments" component={JKPaymentHistory} />
<PrivateRoute path="/account/payment-method" component={JKPaymentMethod} />
<PrivateRoute path="/account/preferences" component={JKAccountPreferences} />
<PrivateRoute path="/affiliate/program" component={JKAffiliateProgram} />
<PrivateRoute path="/affiliate/payee" component={JKAffiliatePayee} />

View File

@ -0,0 +1,507 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Card,
CardBody,
Col,
Button,
Row,
CustomInput,
Label
} from 'reactstrap';
import Flex from '../common/Flex';
import FalconCardHeader from '../common/FalconCardHeader';
import { useTranslation } from 'react-i18next';
import iconPaymentMethodsGrid from '../../assets/img/icons/icon-payment-methods-grid.png';
import iconPaypalFull from '../../assets/img/icons/icon-paypal-full.png';
import { toast } from 'react-toastify';
import { updatePayment } from '../../helpers/rest';
import { useAuth } from '../../context/UserAuth';
import { getBillingInfo, updateBillingInfo, getUserDetail, getCountries } from '../../helpers/rest';
import { useForm, Controller } from 'react-hook-form';
import Select from 'react-select';
import { useResponsive } from '@farfetch/react-context-responsive';
const JKPaymentMethod = () => {
const { t } = useTranslation('account');
const [billingInfo, setBillingInfo] = useState({});
const [hasStoredCreditCard, setHasStoredCreditCard] = useState(false);
const [paymentMethod, setPaymentMethod] = useState('credit-card');
const { currentUser } = useAuth();
const [countries, setCountries] = useState([]);
const labelClassName = 'ls text-600 font-weight-semi-bold mb-0';
const [submitting, setSubmitting] = useState(false);
const [billingDataLoaded, setBillingDataLoaded] = useState(false);
const [isCardValid, setIsCardValid] = useState(false);
const { greaterThan } = useResponsive();
const elementsRef = useRef(null);
const formRef = useRef(null);
const recurlyConfigured = useRef(false);
const paypal = useRef(null);
const {
register,
control,
handleSubmit,
setValue,
formState: { errors }
} = useForm({
defaultValues: {
first_name: '',
last_name: '',
address1: '',
address2: '',
city: '',
state: '',
zip: '',
country: 'US',
}
});
useEffect(() => {
if (currentUser) {
populateUserData();
}
}, [currentUser]);
const populateUserData = async () => {
const options = {
id: currentUser.id
};
try {
const userResp = await getUserDetail(options);
const userData = await userResp.json();
if (userData.has_recurly_account) {
setHasStoredCreditCard(userData.has_stored_credit_card);
await populateBillingAddress();
setBillingDataLoaded(true);
}
} catch (error) {
console.error('Failed to get user details:', error);
}
};
const populateBillingAddress = async () => {
try {
const resp = await getBillingInfo();
const data = await resp.json();
const bi = data.billing_info;
setBillingInfo(bi);
} catch (error) {
console.error('Failed to get billing info:', error);
}
};
useEffect(() => {
if (currentUser) {
fetchCountries();
}
}, [currentUser]);
const fetchCountries = () => {
getCountries()
.then(response => {
if (response.ok) {
return response.json();
}
})
.then(data => {
setCountries(data.countriesx);
})
.catch(error => console.log(error));
};
useEffect(() => {
if (billingInfo) {
setValue('first_name', billingInfo.first_name || '');
setValue('last_name', billingInfo.last_name || '');
setValue('address1', billingInfo.address1 || '');
setValue('address2', billingInfo.address2 || '');
setValue('city', billingInfo.city || '');
setValue('state', billingInfo.state || '');
setValue('zip', billingInfo.zip || '');
setValue('country', billingInfo.country || 'US');
}
}, [billingInfo, setValue]);
const handleCountryChange = selectedOption => {
setValue('country', selectedOption.value);
};
useEffect(() => {
if (!window.recurly) return;
if (recurlyConfigured.current) return;
const interval = setInterval(() => {
const container = document.querySelector('#recurly-elements');
console.log('Checking for Recurly Elements container:', container);
if (container && window.recurly) {
console.log('Initializing Recurly Elements...');
window.recurly.configure({ publicKey: process.env.REACT_APP_RECURLY_PUBLIC_API_KEY });
const elements = window.recurly.Elements();
const cardElement = elements.CardElement();
cardElement.attach('#recurly-elements');
cardElement.on('change', (event) => {
if (event.complete) {
setIsCardValid(true);
} else if (event.error) {
setIsCardValid(false);
} else {
setIsCardValid(false);
}
});
//then load paypal:
const paypalInst = window.recurly.PayPal({ braintree: { clientAuthorization: process.env.REACT_APP_BRAINTREE_TOKEN } })
paypal.current = paypalInst;
paypal.current.on('error', onPayPalError);
paypal.current.on('token', onPayPalToken);
elementsRef.current = elements;
recurlyConfigured.current = true;
clearInterval(interval);
}
}, 100);
return () => clearInterval(interval);
}, []);
const onPayPalError = (error) => {
console.error('PayPal Error:', error);
toast.error('PayPal Error: ' + (error.message || t('payment_method.alerts.try_again')));
setSubmitting(false);
}
const onPayPalToken = (token) => {
handleUpdatePayment(token);
}
const handleUpdatePayment = (token) => {
updatePayment({ recurly_token: token.id }).then((response) => {
toast.success(t('payment_method.alerts.payment_method_updated'));
}).catch((error) => {
console.error('Error updating payment with PayPal token:', error);
if (error.response && error.response.data && error.response.data.message) {
toast.error(error.response.data.message);
} else {
console.error('Error updating payment with PayPal token:', error);
toast.error(t('payment_method.alerts.card_update_error'));
}
}).finally(() => {
setSubmitting(false);
});
};
const onSubmit = async (data) => {
//first update billing address
setSubmitting(true);
const resp = await updateBillingInfo(data)
if (!resp.ok) {
setSubmitting(false);
const errorData = await resp.json();
console.error('Error updating billing info:', errorData);
toast.error(errorData.message || t('payment_method.alerts.billing_update_error'));
return;
}
if (paymentMethod === 'paypal') {
handoverToPaypal();
return;
} else {
if (!elementsRef.current) {
console.error('Recurly elementsRef.current is not ready');
setSubmitting(false);
return;
}
if (!formRef.current) {
console.error('formRef.current is not ready');
setSubmitting(false);
return;
}
if (!isCardValid) {
console.error('Card is not valid');
toast.error(t('payment_method.validations.card.invalid'));
setSubmitting(false);
return;
}
window.recurly.token(elementsRef.current, formRef.current, (err, token) => {
if (err) {
console.error('Recurly token error:', err);
toast.error(err.message || t('payment_method.alerts.card_processing_error'));
setSubmitting(false);
} else {
console.log('Recurly token:', token.id);
// send token.id to backend
handleUpdatePayment(token);
}
});
}
};
const handoverToPaypal = () => {
// Handover to Paypal
setSubmitting(true);
paypal.current.start()
};
return (
<Card>
<FalconCardHeader title={t('payment_method.page_title')} titleClass="font-weight-bold" />
<CardBody className="pt-3" style={{ backgroundColor: '#edf2f9' }}>
<div className='mb-3'>
{hasStoredCreditCard ? (
<span>
<strong>{t('payment_method.help_text_has_card')}</strong>
</span>
) : (
<span>
<strong>{t('payment_method.help_text_no_card')}</strong>
</span>
)}
{t('payment_method.help_text')}
</div>
<form onSubmit={handleSubmit(onSubmit)} ref={formRef}>
<Card style={{ width: greaterThan.sm ? "90%" : '100%' }} className='mx-auto'>
<FalconCardHeader title={t('payment_method.header')} titleTag="h5" />
<CardBody>
<Row>
<Col className="mb-2" xs={12} md={6}>
<Row className="mb-2">
<Col xs={12} md={5} lg={4} className="text-md-right">
<Label for="first_name" className={labelClassName}>
{t('payment_method.first_name')}
</Label>
</Col>
<Col>
<input {...register('first_name', { required: t('payment_method.validations.first_name.required') })} className="form-control" data-recurly="first_name" />
{errors.first_name && (
<div className="text-danger">
<small>{errors.first_name.message}</small>
</div>
)}
</Col>
</Row>
<Row className="mb-2">
<Col xs={12} md={5} lg={4} className="text-md-right">
<Label for="last_name" className={labelClassName}>
{t('payment_method.last_name')}
</Label>
</Col>
<Col>
<input {...register('last_name', { required: t('payment_method.validations.last_name.required') })} className="form-control" data-recurly="last_name" />
{errors.last_name && (
<div className="text-danger">
<small>{errors.last_name.message}</small>
</div>
)}
</Col>
</Row>
<Row className="mb-2">
<Col xs={12} md={5} lg={4} className="text-md-right">
<Label for="address1" className={labelClassName}>
{t('payment_method.address1')}
</Label>
</Col>
<Col>
<input {...register('address1', { required: t('payment_method.validations.address1.required') })} className="form-control" data-recurly="address1" />
{errors.address1 && (
<div className="text-danger">
<small>{errors.address1.message}</small>
</div>
)}
</Col>
</Row>
<Row className="mb-2">
<Col xs={12} md={5} lg={4} className="text-md-right">
<Label for="address2" className={labelClassName}>
{t('payment_method.address2')}
</Label>
</Col>
<Col>
<input {...register('address2')} className="form-control" data-recurly="address2" />
{errors.address2 && (
<div className="text-danger">
<small>{errors.address2.message}</small>
</div>
)}
</Col>
</Row>
<Row className="mb-2">
<Col xs={12} md={5} lg={4} className="text-md-right">
<Label for="city" className={labelClassName}>
{t('payment_method.city')}
</Label>
</Col>
<Col>
<input {...register('city', { required: t('payment_method.validations.city.required') })} className="form-control" data-recurly="city" />
{errors.city && (
<div className="text-danger">
<small>{errors.city.message}</small>
</div>
)}
</Col>
</Row>
<Row className="mb-2">
<Col xs={12} md={5} lg={4} className="text-md-right">
<Label for="state" className={labelClassName}>
{t('payment_method.state')}
</Label>
</Col>
<Col>
<input {...register('state', { required: t('payment_method.validations.state.required') })} className="form-control" data-recurly="state" />
{errors.state && (
<div className="text-danger">
<small>{errors.state.message}</small>
</div>
)}
</Col>
</Row>
<Row className="mb-2">
<Col xs={12} md={5} lg={4} className="text-md-right">
<Label for="zip" className={labelClassName}>
{t('payment_method.zip_code')}
</Label>
</Col>
<Col>
<input
{...register('zip', { required: t('payment_method.validations.zip_code.required') })}
className="form-control" data-recurly="postal_code"
/>
{errors.zip && (
<div className="text-danger">
<small>{errors.zip.message}</small>
</div>
)}
</Col>
</Row>
<Row className="mb-2">
<Col xs={12} md={5} lg={4} className="text-md-right">
<Label for="country" className={labelClassName}>
{t('payment_method.country')}
</Label>
</Col>
<Col>
<Controller
name="country"
control={control}
rules={{ required: t('payment_method.validations.country.required') }}
render={({ field: { onChange, value } }) => {
const country = countries.find(country => country.countrycode === value);
if (!country) {
return (
<Select
data-testid="countrySelect"
data-recurly="country"
onChange={handleCountryChange}
options={countries.map(c => {
return { value: c.countrycode, label: c.countryname };
})}
/>
);
}
return (
<Select
data-testid="countrySelect"
data-recurly="country"
value={{ value: country.countrycode, label: country.countryname }}
onChange={handleCountryChange}
options={countries.map(c => {
return { value: c.countrycode, label: c.countryname };
})}
/>
);
}}
/>
<input type="hidden" name="country" data-recurly="country" {...register('country')} />
{errors.country && (
<div className="text-danger">
<small>{errors.country.message}</small>
</div>
)}
</Col>
</Row>
</Col>
<Col xs={12} md={6} className="mb-2 pl-5">
<Row>
<Col xs={12}>
<CustomInput
label={
<Flex align="center" className="mb-2 fs-1">
<span>{t('payment_method.credit_card')}</span>
</Flex>
}
id="credit-card"
value="credit-card"
checked={paymentMethod === 'credit-card'}
onChange={({ target }) => setPaymentMethod(target.value)}
type="radio"
/>
</Col>
</Row>
<Row>
<Col sm={8}>
<div id="recurly-elements"></div>
{!isCardValid && errors.recurly && (
<div className="text-danger">
<small>{errors.recurly.message}</small>
</div>
)}
<input type="hidden" name="recurly-token" data-recurly="token" />
</Col>
<div className="col-4 text-center pt-2 d-none d-sm-block">
<div className="rounded p-2 mt-3 bg-100">
<div className="text-uppercase fs--2 font-weight-bold">{t('payment_method.we_accept')}</div>
<img src={iconPaymentMethodsGrid} alt="" width="120" />
</div>
</div>
</Row>
<hr />
<Row className="mt-3">
<Col xs={12}>
<CustomInput
label={<img className="pull-right" src={iconPaypalFull} height="20" alt="" />}
id="paypal"
value="paypal"
checked={paymentMethod === 'paypal'}
onChange={({ target }) => setPaymentMethod(target.value)}
type="radio"
/>
</Col>
</Row>
<div className="d-flex justify-content-center">
<Button
color="primary"
type="submit"
disabled={submitting || !billingDataLoaded }
className="mt-3"
>
{submitting ? t('payment_method.submitting') : t('payment_method.save_payment_info')}
</Button>
</div>
<div className="text-center">
<p className="fs--1 mt-3 mb-0">
{t('payment_method.aggreement.text1')} <strong>{t('payment_method.aggreement.text2')} </strong>{t('payment_method.aggreement.text3')}{' '}
<br />
<a href="https://www.jamkazam.com/corp/terms" target='_blank'>{t('payment_method.aggreement.terms_of_service')}</a>
</p>
</div>
</Col>
</Row>
</CardBody>
</Card>
</form>
</CardBody>
</Card>
);
};
export default JKPaymentMethod;

View File

@ -554,6 +554,18 @@ export const getBillingInfo = () => {
});
};
export const updateBillingInfo = (options = {}) => {
const params = { billing_info: options };
return new Promise((resolve, reject) => {
apiFetch(`/recurly/update_billing_info`, {
method: 'PUT',
body: JSON.stringify(params)
})
.then(response => resolve(response))
.catch(error => reject(error));
});
};
export const createRecurlyAccount = (options = {}) => {
return new Promise((resolve, reject) => {
apiFetch(`/recurly/create_account`, {
@ -704,3 +716,37 @@ export const paypalPlaceOrder = (options = {}) => {
.catch(error => reject(error));
});
};
export const submitStripe = (options = {}) => {
return new Promise((resolve, reject) => {
apiFetch(`/stripe`, {
method: 'POST',
body: JSON.stringify(options)
})
.then(response => resolve(response))
.catch(error => reject(error));
})
}
// function updatePayment(options) {
// options = options || {}
// return $.ajax({
// type: "POST",
// url: '/api/recurly/update_payment',
// dataType: "json",
// contentType: 'application/json',
// data: JSON.stringify(options)
// })
// }
export const updatePayment = (options = {}) => {
return new Promise((resolve, reject) => {
apiFetch(`/recurly/update_payment`, {
method: 'POST',
body: JSON.stringify(options)
})
.then(response => resolve(response))
.catch(error => reject(error));
});
};

View File

@ -109,5 +109,68 @@
"no_payments": "No payments found.",
"load_more": "Load More",
"loading": "Loading..."
},
"payment_method": {
"page_title": "Payment Method",
"header": "Address and Payment Method",
"help_text_no_card": "You do not currently have a payment method on file.",
"help_text_has_card": "You currently have a payment method on file.",
"help_text": "To update your payment method, first enter your billing address and click the save button. If credit card, enter your card information and click the Save button. If PayPal, click the save button and follow PayPal's on-screen instructions to sign into your account and authorize payment to JamKazam.",
"credit_card_number": "Credit Card Number",
"expiration_date": "Expiration Date (MM/YY)",
"cvv": "CVV",
"submit": "Save Payment Method",
"first_name": "First Name",
"last_name": "Last Name",
"address1": "Address 1",
"address2": "Address 2",
"city": "City",
"state": "State or Region",
"zip_code": "Zip/Postal Code",
"country": "Country",
"credit_card": "Credit Card",
"paypal": "PayPal",
"we_accept": "We accept",
"submitting": "Submitting...",
"save_payment_info": "Save Payment Information",
"validations": {
"first_name": {
"required": "First Name is required"
},
"last_name": {
"required": "Last Name is required"
},
"address1": {
"required": "Address Line 1 is required"
},
"city": {
"required": "City is required"
},
"state": {
"required": "State or Region is required"
},
"zip_code": {
"required": "Zip/Postal Code is required"
},
"country": {
"required": "Country is required"
},
"card": {
"invalid": "Credit card details are invalid"
}
},
"aggreement": {
"text1": "By clicking",
"text2": "Save Payment Information",
"text3": "you agree to JamKazam's",
"terms_of_service": "Terms of Service"
},
"alerts": {
"try_again": "Please try again.",
"payment_method_updated": "Your payment method has been successfully updated.",
"card_update_error": "Failed to update payment method. Please try again later.",
"card_processing_error": "There was an error processing your card. Please check your details and try again.",
"billing_update_error": "There was an error processing your billing information. Please check your details and try again."
}
}
}

View File

@ -619,7 +619,7 @@ profileUtils = context.JK.ProfileUtils
<a className={classNames(submitClassNames)} onClick={this.onSubmitForm}>SUBMIT CARD INFORMATION</a>
</div>`
else
header = 'You have have a payment method on file already.'
header = 'You have a payment method on file already.'
updateCardAction = `<a className={classNames(updateCardClassNames)} onClick={this.onUnlockPaymentInfo}>I'D LIKE TO UPDATE MY PAYMENT INFO</a>`
managedSubscriptionAction = `<a href="/client#/account/subscription" className="button-orange">MANAGE MY SUBSCRIPTION</a>`
actions = `<div className="actions">

View File

@ -4,7 +4,7 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do
resource '/api/*',
headers: :any,
methods: [:get, :post, :delete, :options],
methods: [:get, :post, :put, :delete, :options],
credentials: true
end