From 2f750314ba3ff021361fbf1646ee2c5cf48aad90 Mon Sep 17 00:00:00 2001 From: Christiaan Scheermeijer Date: Thu, 22 Jul 2021 15:31:25 +0200 Subject: [PATCH] feat(auth): add ChooseOfferForm --- public/config.json | 6 +- .../ChooseOfferForm.module.scss | 102 +++++++++ .../ChooseOfferForm/ChooseOfferForm.test.tsx | 96 ++++++++ .../ChooseOfferForm/ChooseOfferForm.tsx | 124 ++++++++++ .../ChooseOfferForm.test.tsx.snap | 215 ++++++++++++++++++ .../FormFeedback/FormFeedback.module.scss | 23 ++ .../FormFeedback/FormFeedback.test.tsx | 12 + src/components/FormFeedback/FormFeedback.tsx | 24 ++ .../__snapshots__/FormFeedback.test.tsx.snap | 11 + .../LoadingOverlay/LoadingOverlay.module.scss | 19 +- .../LoadingOverlay/LoadingOverlay.tsx | 13 +- .../LoginForm/LoginForm.module.scss | 10 - src/components/LoginForm/LoginForm.tsx | 3 +- src/containers/AccountModal/AccountModal.tsx | 5 +- .../AccountModal/forms/ChooseOffer.tsx | 54 +++++ src/fixtures/monthlyOffer.json | 44 ++++ src/fixtures/yearlyOffer.json | 45 ++++ src/i18n/locales/en_US/account.json | 28 +++ src/i18n/locales/nl_NL/account.json | 24 ++ src/icons/CheckCircle.tsx | 10 + src/services/checkout.service.ts | 7 + src/styles/_theme.scss | 6 +- src/styles/_variables.scss | 1 + src/utils/formatting.ts | 20 +- src/utils/subscription.ts | 8 + types/account.d.ts | 8 + types/checkout.d.ts | 8 +- 27 files changed, 892 insertions(+), 34 deletions(-) create mode 100644 src/components/ChooseOfferForm/ChooseOfferForm.module.scss create mode 100644 src/components/ChooseOfferForm/ChooseOfferForm.test.tsx create mode 100644 src/components/ChooseOfferForm/ChooseOfferForm.tsx create mode 100644 src/components/ChooseOfferForm/__snapshots__/ChooseOfferForm.test.tsx.snap create mode 100644 src/components/FormFeedback/FormFeedback.module.scss create mode 100644 src/components/FormFeedback/FormFeedback.test.tsx create mode 100644 src/components/FormFeedback/FormFeedback.tsx create mode 100644 src/components/FormFeedback/__snapshots__/FormFeedback.test.tsx.snap create mode 100644 src/containers/AccountModal/forms/ChooseOffer.tsx create mode 100644 src/fixtures/monthlyOffer.json create mode 100644 src/fixtures/yearlyOffer.json create mode 100644 src/icons/CheckCircle.tsx create mode 100644 src/services/checkout.service.ts create mode 100644 src/utils/subscription.ts diff --git a/public/config.json b/public/config.json index 753685040..f4a27ec81 100644 --- a/public/config.json +++ b/public/config.json @@ -45,5 +45,9 @@ "player": "7xMa4gNw", "recommendationsPlaylist": "fuD6TWcf", "searchPlaylist": "tQ832H1H", - "siteName": "Blender" + "siteName": "Blender", + "json": { + "cleengMonthlyOffer": "S916977979_NL", + "cleengYearlyOffer": "S345569153_NL" + } } diff --git a/src/components/ChooseOfferForm/ChooseOfferForm.module.scss b/src/components/ChooseOfferForm/ChooseOfferForm.module.scss new file mode 100644 index 000000000..d76e991d2 --- /dev/null +++ b/src/components/ChooseOfferForm/ChooseOfferForm.module.scss @@ -0,0 +1,102 @@ +@use '../../styles/variables'; +@use '../../styles/theme'; + +.title { + margin-bottom: 8px; + font-family: theme.$body-alt-font-family; + font-weight: 700; + font-size: 24px; +} + +.subtitle { + margin-bottom: 24px; + font-family: theme.$body-alt-font-family; + font-weight: 700; + font-size: 18px; +} + +.offers { + display: flex; + width: 100%; + margin: 0 -4px 24px; +} + +.offer { + flex: 1; + margin: 0 4px; +} + +.radio { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + white-space: nowrap; + clip: rect(0 0 0 0); + clip-path: inset(50%); + + :focus, + :active { + + .label { + border-color: variables.$white; + } + } + + &:checked + .label { + color: variables.$black; + background-color: variables.$white; + border-color: variables.$white; + } +} + +.label { + display: block; + padding: 16px; + font-family: theme.$body-font-family; + background-color: rgba(variables.$black, 0.34); + border: 1px solid rgba(variables.$white, 0.34); + border-radius: 4px; + cursor: pointer; + transition: border 0.2s ease, background 0.2s ease; +} + +.offerTitle { + font-weight: 700; + font-size: 20px; + text-align: center; +} + +.offerDivider { + border: none; + border-bottom: 1px solid currentColor; + opacity: 0.54; +} + +.offerBenefits { + margin-bottom: 16px; + padding: 0; + + > li { + display: flex; + align-items: center; + margin-bottom: 4px; + padding: 4px 0; + + > svg { + margin-right: 4px; + fill: variables.$green; + } + } +} + +.offerPrice { + display: flex; + justify-content: center; + align-items: baseline; + font-size: 32px; + + > small { + margin-left: 4px; + font-size: 12px; + } +} diff --git a/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx b/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx new file mode 100644 index 000000000..1df1bac92 --- /dev/null +++ b/src/components/ChooseOfferForm/ChooseOfferForm.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import monthlyOffer from '../../fixtures/monthlyOffer.json'; +import yearlyOffer from '../../fixtures/yearlyOffer.json'; +import type { Offer } from '../../../types/checkout'; + +import ChooseOfferForm from './ChooseOfferForm'; + +describe('', () => { + test('renders and matches snapshot', () => { + const { container } = render( + , + ); + + expect(container).toMatchSnapshot(); + }); + + test('checks the monthly offer correctly', () => { + const { getByLabelText } = render( + , + ); + + expect(getByLabelText('choose_offer.monthly_subscription')).toBeChecked(); + }); + + test('checks the yearly offer correctly', () => { + const { getByLabelText } = render( + , + ); + + expect(getByLabelText('choose_offer.yearly_subscription')).toBeChecked(); + }); + + test('calls the onChange callback when changing the offer', () => { + const onChange = jest.fn(); + const { getByLabelText } = render( + , + ); + + fireEvent.click(getByLabelText('choose_offer.yearly_subscription')); + + expect(onChange).toBeCalled(); + }); + + test('calls the onSubmit callback when submitting the form', () => { + const onSubmit = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.submit(getByTestId('choose-offer-form')); + + expect(onSubmit).toBeCalled(); + }); +}); diff --git a/src/components/ChooseOfferForm/ChooseOfferForm.tsx b/src/components/ChooseOfferForm/ChooseOfferForm.tsx new file mode 100644 index 000000000..2f003f960 --- /dev/null +++ b/src/components/ChooseOfferForm/ChooseOfferForm.tsx @@ -0,0 +1,124 @@ +import React, { useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ChooseOfferFormData } from 'types/account'; + +import Button from '../Button/Button'; +import CheckCircle from '../../icons/CheckCircle'; +import { ConfigContext } from '../../providers/ConfigProvider'; +import type { FormErrors } from '../../hooks/useForm'; +import type { Offer } from '../../../types/checkout'; + +import styles from './ChooseOfferForm.module.scss'; +import FormFeedback from '../FormFeedback/FormFeedback'; +import { getOfferPrice } from '../../utils/subscription'; + +type Props = { + values: ChooseOfferFormData; + errors: FormErrors; + onChange: React.ChangeEventHandler; + onSubmit: React.FormEventHandler; + monthlyOffer?: Offer; + yearlyOffer?: Offer; + submitting: boolean; +}; + +const ChooseOfferForm: React.FC = ({ values, errors, onChange, onSubmit, submitting, yearlyOffer, monthlyOffer }: Props) => { + const { siteName } = useContext(ConfigContext); + const { t } = useTranslation('account'); + + const getFreeTrialText = (offer: Offer) => { + if (offer.freeDays > 0) { + return t('choose_offer.benefits.first_days_free', { count: offer.freeDays }); + } else if (offer.freePeriods) { + // t('choose_offer.periods.day') + // t('choose_offer.periods.week') + // t('choose_offer.periods.month') + // t('choose_offer.periods.year') + const period = t(`choose_offer.periods.${offer.period}`, { count: offer.freePeriods }); + + return t('choose_offer.benefits.first_periods_free', { count: offer.freePeriods, period }); + } + + return null; + } + + return ( +
+

{t('choose_offer.subscription')}

+

{t('choose_offer.all_movies_and_series_of_platform', { siteName })}

+ {errors.form ? {errors.form} : null} +
+ {monthlyOffer ? ( +
+ + +
+ ) : null} + {yearlyOffer ? ( +
+ + +
+ ) : null} +
+
+ + +`; diff --git a/src/components/FormFeedback/FormFeedback.module.scss b/src/components/FormFeedback/FormFeedback.module.scss new file mode 100644 index 000000000..50390f986 --- /dev/null +++ b/src/components/FormFeedback/FormFeedback.module.scss @@ -0,0 +1,23 @@ +@use '../../styles/variables'; +@use '../../styles/theme'; + +.formFeedback { + margin-bottom: 24px; + padding: 16px; + color: variables.$white; + font-family: theme.$body-font-family; + font-size: 18px; + border-radius: 4px; +} + +.error { + background-color: theme.$form-feedback-error-bg-color; +} + +.warning { + background-color: theme.$form-feedback-warning-bg-color; +} + +.success { + background-color: theme.$form-feedback-success-bg-color; +} diff --git a/src/components/FormFeedback/FormFeedback.test.tsx b/src/components/FormFeedback/FormFeedback.test.tsx new file mode 100644 index 000000000..dccbb029d --- /dev/null +++ b/src/components/FormFeedback/FormFeedback.test.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import FormFeedback from './FormFeedback'; + +describe('', () => { + test('renders and matches snapshot', () => { + const { container } = render(Form feedback); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/components/FormFeedback/FormFeedback.tsx b/src/components/FormFeedback/FormFeedback.tsx new file mode 100644 index 000000000..6f99da5bb --- /dev/null +++ b/src/components/FormFeedback/FormFeedback.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import classNames from 'classnames'; + +import styles from './FormFeedback.module.scss'; + +type Props = { + children?: React.ReactNode; + variant: 'success' | 'warning' | 'error'; +}; + +const FormFeedback: React.FC = ({ children, variant = 'error' }: Props) => { + const className = classNames(styles.formFeedback, { + [styles.error]: variant === 'error', + [styles.warning]: variant === 'warning', + [styles.success]: variant === 'success', + }); + return ( +
+ {children} +
+ ); +}; + +export default FormFeedback; diff --git a/src/components/FormFeedback/__snapshots__/FormFeedback.test.tsx.snap b/src/components/FormFeedback/__snapshots__/FormFeedback.test.tsx.snap new file mode 100644 index 000000000..e6123458e --- /dev/null +++ b/src/components/FormFeedback/__snapshots__/FormFeedback.test.tsx.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders and matches snapshot 1`] = ` +
+
+ Form feedback +
+
+`; diff --git a/src/components/LoadingOverlay/LoadingOverlay.module.scss b/src/components/LoadingOverlay/LoadingOverlay.module.scss index 83c12abe3..b4d3da4c9 100644 --- a/src/components/LoadingOverlay/LoadingOverlay.module.scss +++ b/src/components/LoadingOverlay/LoadingOverlay.module.scss @@ -2,19 +2,30 @@ @use '../../styles/theme'; .loadingOverlay { + display: flex; + justify-content: center; + align-items: center; +} + +.fixed { position: fixed; top: 0; left: 0; z-index: variables.$loading-z-index; - display: flex; - justify-content: center; - align-items: center; width: 100vw; height: 100vh; - background-color: var(--body-background-color); } +.inline { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(variables.$black, 0.3); +} + .buffer { position: relative; display: inline-block; diff --git a/src/components/LoadingOverlay/LoadingOverlay.tsx b/src/components/LoadingOverlay/LoadingOverlay.tsx index 88946f393..dfbdf8bb9 100644 --- a/src/components/LoadingOverlay/LoadingOverlay.tsx +++ b/src/components/LoadingOverlay/LoadingOverlay.tsx @@ -1,10 +1,19 @@ import React from 'react'; +import classNames from 'classnames'; import styles from './LoadingOverlay.module.scss'; -const LoadingOverlay: React.FC = () => { +type Props = { + inline?: boolean; +} + +const LoadingOverlay: React.FC = ({ inline = false }) => { + const className = classNames(styles.loadingOverlay, { + [styles.fixed]: !inline, + [styles.inline]: inline, + }) return ( -
+
diff --git a/src/components/LoginForm/LoginForm.module.scss b/src/components/LoginForm/LoginForm.module.scss index 96c438e6b..d620c690c 100644 --- a/src/components/LoginForm/LoginForm.module.scss +++ b/src/components/LoginForm/LoginForm.module.scss @@ -8,16 +8,6 @@ font-size: 24px; } -.error { - margin-bottom: 24px; - padding: 16px; - color: variables.$white; - font-family: theme.$body-font-family; - font-size: 18px; - background-color: theme.$form-error-bg-color; - border-radius: 4px; -} - .link { margin-bottom: 24px; } diff --git a/src/components/LoginForm/LoginForm.tsx b/src/components/LoginForm/LoginForm.tsx index 5722ed811..99ea622f6 100644 --- a/src/components/LoginForm/LoginForm.tsx +++ b/src/components/LoginForm/LoginForm.tsx @@ -14,6 +14,7 @@ import VisibilityOff from '../../icons/VisibilityOff'; import type { FormErrors } from '../../hooks/useForm'; import styles from './LoginForm.module.scss'; +import FormFeedback from '../FormFeedback/FormFeedback'; type Props = { onSubmit: React.FormEventHandler; @@ -32,7 +33,7 @@ const LoginForm: React.FC = ({ onSubmit, onChange, values, errors, submit return (

{t('login.sign_in')}

- {errors.form ?
{errors.form}
: null} + {errors.form ? {errors.form} : null} { const history = useHistory(); @@ -24,8 +25,8 @@ const AccountModal = () => { return (
{banner ? : null}
- - + {view === 'login' ? : null} + {view === 'choose-offer' ? : null}
); }; diff --git a/src/containers/AccountModal/forms/ChooseOffer.tsx b/src/containers/AccountModal/forms/ChooseOffer.tsx new file mode 100644 index 000000000..fe7eb8eb9 --- /dev/null +++ b/src/containers/AccountModal/forms/ChooseOffer.tsx @@ -0,0 +1,54 @@ +import React, { useContext } from 'react'; +import { object, SchemaOf, mixed } from 'yup'; +import type { ChooseOfferFormData, OfferPeriodicity } from 'types/account'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from 'react-query'; + +import useForm, { UseFormOnSubmitHandler } from '../../../hooks/useForm'; +import ChooseOfferForm from '../../../components/ChooseOfferForm/ChooseOfferForm'; +import { getOffer } from '../../../services/checkout.service'; +import { ConfigContext } from '../../../providers/ConfigProvider'; +import LoadingOverlay from '../../../components/LoadingOverlay/LoadingOverlay'; + +const ChooseOffer = () => { + const { t } = useTranslation('account'); + const { cleengSandbox, json } = useContext(ConfigContext); + + const cleengMonthlyOffer = json?.cleengMonthlyOffer as string; + const cleengYearlyOffer = json?.cleengYearlyOffer as string; + + // `useQueries` is not strongly typed :-( + const { data: monthlyOfferData } = useQuery(['offer', cleengMonthlyOffer], () => getOffer({ offerId: cleengMonthlyOffer }, cleengSandbox)); + const { data: yearlyOfferData } = useQuery(['offer', cleengYearlyOffer], () => getOffer({ offerId: cleengYearlyOffer }, cleengSandbox)); + + const chooseOfferSubmitHandler: UseFormOnSubmitHandler = async (formData, { setSubmitting }) => { + console.info('OfferForm submit', formData); + + setSubmitting(false); + }; + + const validationSchema: SchemaOf = object().shape({ + periodicity: mixed().required(t('choose_offer.field_required')), + }); + const initialValues: ChooseOfferFormData = { periodicity: 'monthly' }; + const { handleSubmit, handleChange, values, errors, submitting } = useForm(initialValues, chooseOfferSubmitHandler, validationSchema); + + // loading state + if (!monthlyOfferData?.responseData || !yearlyOfferData?.responseData) { + return
; + } + + return ( + + ); +}; + +export default ChooseOffer; diff --git a/src/fixtures/monthlyOffer.json b/src/fixtures/monthlyOffer.json new file mode 100644 index 000000000..1afe5bf2f --- /dev/null +++ b/src/fixtures/monthlyOffer.json @@ -0,0 +1,44 @@ +{ + "offerId": "S916977979_NL", + "offerPrice": 6.99, + "offerCurrency": "EUR", + "offerCurrencySymbol": "€", + "offerCountry": "NL", + "customerPriceInclTax": 6.99, + "customerPriceExclTax": 5.77, + "customerCurrency": "EUR", + "customerCurrencySymbol": "€", + "customerCountry": "NL", + "discountedCustomerPriceInclTax": null, + "discountedCustomerPriceExclTax": null, + "discountPeriods": null, + "offerUrl": "http://domain.com", + "offerTitle": "Monthly subscription (recurring) to JW OTT Webapp", + "offerDescription": null, + "active": true, + "createdAt": 1619600070, + "updatedAt": 1626952636, + "applicableTaxRate": 0.21, + "geoRestrictionEnabled": false, + "geoRestrictionType": null, + "geoRestrictionCountries": [], + "socialCommissionRate": 0, + "averageRating": 4, + "contentType": null, + "period": "month", + "freePeriods": 1, + "freeDays": 0, + "expiresAt": null, + "accessToTags": [ + "(all)" + ], + "videoId": null, + "contentExternalId": null, + "contentExternalData": null, + "contentAgeRestriction": null, + "trialAvailable": null, + "applyServiceFeeOnCustomer": false, + "timeZone": null, + "startTime": null, + "endTime": null +} diff --git a/src/fixtures/yearlyOffer.json b/src/fixtures/yearlyOffer.json new file mode 100644 index 000000000..221d7e67f --- /dev/null +++ b/src/fixtures/yearlyOffer.json @@ -0,0 +1,45 @@ +{ + "offerId": "S345569153_NL", + "offerPrice": 50, + "offerCurrency": "EUR", + "offerCurrencySymbol": "€", + "offerCountry": "NL", + "customerPriceInclTax": 50, + "customerPriceExclTax": 41.32, + "customerCurrency": "EUR", + "customerCurrencySymbol": "€", + "customerCountry": "NL", + "discountedCustomerPriceInclTax": null, + "discountedCustomerPriceExclTax": null, + "discountPeriods": null, + "offerUrl": "http://domain.com", + "offerTitle": "Annual subscription (recurring) to JW OTT Webapp", + "offerDescription": null, + "active": true, + "createdAt": 1619696159, + "updatedAt": 1626952624, + "applicableTaxRate": 0.21, + "geoRestrictionEnabled": false, + "geoRestrictionType": null, + "geoRestrictionCountries": [], + "socialCommissionRate": 0, + "averageRating": 4, + "contentType": null, + "period": "year", + "freePeriods": 0, + "freeDays": 14, + "expiresAt": null, + "accessToTags": [ + "(all)" + ], + "videoId": null, + "contentExternalId": null, + "contentExternalData": null, + "contentAgeRestriction": null, + "trialAvailable": null, + "applyServiceFeeOnCustomer": false, + "timeZone": null, + "startTime": null, + "endTime": null +} + diff --git a/src/i18n/locales/en_US/account.json b/src/i18n/locales/en_US/account.json index fbdfff263..e68b391c3 100644 --- a/src/i18n/locales/en_US/account.json +++ b/src/i18n/locales/en_US/account.json @@ -1,4 +1,32 @@ { + "choose_offer": { + "all_movies_and_series_of_platform": "All movies and series of {{siteName}}", + "benefits": { + "cancel_anytime": "Cancel anytime", + "first_days_free": "First day free", + "first_days_free_plural": "First {{count}} days free", + "first_periods_free": "First {{period}} free", + "first_periods_free_plural": "First {{count}} {{period}} days free", + "watch_on_all_devices": "Watch on all devices" + }, + "continue": "Continue", + "field_required": "This field is required", + "monthly": "Monthly", + "monthly_subscription": "Monthly subscription", + "periods": { + "day": "day", + "month": "month", + "week": "week", + "year": "year", + "day_plural": "days", + "week_plural": "weeks", + "month_plural": "months", + "year_plural": "years" + }, + "subscription": "Subscription", + "yearly": "Yearly", + "yearly_subscription": "Yearly subscription" + }, "login": { "email": "Email", "field_is_not_valid_email": "Please re-enter your email details and try again.", diff --git a/src/i18n/locales/nl_NL/account.json b/src/i18n/locales/nl_NL/account.json index 3e615e06a..237a9eb5d 100644 --- a/src/i18n/locales/nl_NL/account.json +++ b/src/i18n/locales/nl_NL/account.json @@ -1,4 +1,28 @@ { + "choose_offer": { + "all_movies_and_series_of_platform": "", + "benefits": { + "cancel_anytime": "", + "first_days_free": "", + "first_days_free_plural": "", + "first_periods_free": "", + "first_periods_free_plural": "", + "watch_on_all_devices": "" + }, + "continue": "", + "field_required": "", + "monthly": "", + "monthly_subscription": "", + "periods": { + "day": "", + "month": "", + "week": "", + "year": "" + }, + "subscription": "", + "yearly": "", + "yearly_subscription": "" + }, "login": { "email": "", "field_is_not_valid_email": "", diff --git a/src/icons/CheckCircle.tsx b/src/icons/CheckCircle.tsx new file mode 100644 index 000000000..b43339f4c --- /dev/null +++ b/src/icons/CheckCircle.tsx @@ -0,0 +1,10 @@ +import React from 'react'; + +import createIcon from './Icon'; + +export default createIcon( + '0 0 24 24', + + + , +); diff --git a/src/services/checkout.service.ts b/src/services/checkout.service.ts new file mode 100644 index 000000000..304d0f564 --- /dev/null +++ b/src/services/checkout.service.ts @@ -0,0 +1,7 @@ +import type { GetOffer } from '../../types/checkout'; + +import { get } from './cleeng.service'; + +export const getOffer: GetOffer = async (payload, sandbox) => { + return get(sandbox, `/offers/${payload.offerId}`, JSON.stringify(payload)); +}; diff --git a/src/styles/_theme.scss b/src/styles/_theme.scss index 1b31f0632..1a604c52b 100644 --- a/src/styles/_theme.scss +++ b/src/styles/_theme.scss @@ -90,8 +90,10 @@ $cookies-title-color: $primary-color !default; $cookies-color: $dark-color !default; $cookies-bg: variables.$white !default; -// Form -$form-error-bg-color: #FF0C3E !default; +// FormFeedback +$form-feedback-error-bg-color: #FF0C3E !default; +$form-feedback-warning-bg-color: variables.$orange !default; +$form-feedback-success-bg-color: variables.$green !default; // Footer diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss index c9d9660c2..022d68925 100644 --- a/src/styles/_variables.scss +++ b/src/styles/_variables.scss @@ -15,6 +15,7 @@ $gray-alternate: #edeff3 !default; $red: #ce153f !default; $blue: #2b3b57 !default; $green: #393 !default; +$orange: #e9a95b !default; // // Base diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 0de17ac69..7020f65c3 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -2,7 +2,7 @@ import type { Playlist, PlaylistItem } from 'types/playlist'; import { getSeriesId, getSeriesIdFromEpisode, isEpisode, isSeriesPlaceholder } from './media'; -const formatDurationTag = (seconds: number): string | null => { +export const formatDurationTag = (seconds: number): string | null => { if (!seconds || typeof seconds !== 'number') return null; const minutes = Math.ceil(seconds / 60); @@ -20,7 +20,7 @@ const formatDurationTag = (seconds: number): string | null => { * @returns string, such as '2h 24m' or '31m' */ -const formatDuration = (duration: number): string | null => { +export const formatDuration = (duration: number): string | null => { if (!duration || typeof duration !== 'number') return null; const hours = Math.floor(duration / 3600); @@ -49,7 +49,7 @@ export const addQueryParams = (url: string, queryParams: { [key: string]: string return `${urlWithoutSearch}${queryString ? `?${queryString}` : ''}`; }; -const slugify = (text: string, whitespaceChar: string = '-') => +export const slugify = (text: string, whitespaceChar: string = '-') => text .toString() .toLowerCase() @@ -60,23 +60,23 @@ const slugify = (text: string, whitespaceChar: string = '-') => .replace(/-+$/, '') .replace(/-/g, whitespaceChar); -const movieURL = (item: PlaylistItem, playlistId?: string | null, play: boolean = false) => +export const movieURL = (item: PlaylistItem, playlistId?: string | null, play: boolean = false) => addQueryParams(`/m/${item.mediaid}/${slugify(item.title)}`, { r: playlistId, play: play ? '1' : null }); -const seriesURL = (item: PlaylistItem, playlistId?: string | null, play: boolean = false) => { +export const seriesURL = (item: PlaylistItem, playlistId?: string | null, play: boolean = false) => { const seriesId = getSeriesId(item); return addQueryParams(`/s/${seriesId}/${slugify(item.title)}`, { r: playlistId, play: play ? '1' : null }); }; -const episodeURL = (seriesPlaylist: Playlist, episodeId?: string, play: boolean = false, playlistId?: string | null) => +export const episodeURL = (seriesPlaylist: Playlist, episodeId?: string, play: boolean = false, playlistId?: string | null) => addQueryParams(`/s/${seriesPlaylist.feedid}/${slugify(seriesPlaylist.title)}`, { e: episodeId, r: playlistId, play: play ? '1' : null, }); -const episodeURLFromEpisode = (item: PlaylistItem, seriesId: string, playlistId?: string | null, play: boolean = false) => { +export const episodeURLFromEpisode = (item: PlaylistItem, seriesId: string, playlistId?: string | null, play: boolean = false) => { // generated URL does not match the canonical URL. We need the series playlist in order to generate the slug. For // now the item title is used instead. The canonical link isn't affected by this though. return addQueryParams(`/s/${seriesId}/${slugify(item.title)}`, { @@ -86,7 +86,7 @@ const episodeURLFromEpisode = (item: PlaylistItem, seriesId: string, playlistId? }); }; -const cardUrl = (item: PlaylistItem, playlistId?: string | null, play: boolean = false) => { +export const cardUrl = (item: PlaylistItem, playlistId?: string | null, play: boolean = false) => { if (isEpisode(item)) { const seriesId = getSeriesIdFromEpisode(item); @@ -96,9 +96,7 @@ const cardUrl = (item: PlaylistItem, playlistId?: string | null, play: boolean = return isSeriesPlaceholder(item) ? seriesURL(item, playlistId, play) : movieURL(item, playlistId, play); }; -const videoUrl = (item: PlaylistItem, playlistId?: string | null, play: boolean = false) => +export const videoUrl = (item: PlaylistItem, playlistId?: string | null, play: boolean = false) => addQueryParams(item.seriesId ? seriesURL(item, playlistId) : movieURL(item, playlistId), { play: play ? '1' : null, }); - -export { formatDurationTag, formatDuration, cardUrl, movieURL, seriesURL, videoUrl, episodeURL }; diff --git a/src/utils/subscription.ts b/src/utils/subscription.ts new file mode 100644 index 000000000..89f3e2dc3 --- /dev/null +++ b/src/utils/subscription.ts @@ -0,0 +1,8 @@ +import type { Offer } from '../../types/checkout'; + +export const getOfferPrice = (offer: Offer) => { + return new Intl.NumberFormat(offer.customerCountry, { + style: 'currency', + currency: offer.customerCurrency, + }).format(offer.customerPriceInclTax); +}; diff --git a/types/account.d.ts b/types/account.d.ts index f3d66521c..1e3a1f8e5 100644 --- a/types/account.d.ts +++ b/types/account.d.ts @@ -1,3 +1,5 @@ +import type { Offer } from './checkout'; + export type AuthData = { jwt: string; customerToken: string; @@ -21,6 +23,12 @@ export type LoginFormData = { password: string; }; +export type OfferPeriodicity = 'monthly' | 'yearly'; + +export type ChooseOfferFormData = { + periodicity: OfferPeriodicity; +}; + export type RegisterPayload = { email: string; password: string; diff --git a/types/checkout.d.ts b/types/checkout.d.ts index b0ede8390..ba7812778 100644 --- a/types/checkout.d.ts +++ b/types/checkout.d.ts @@ -25,7 +25,7 @@ export type Offer = { socialCommissionRate: number; averageRating: number; contentType: string | null; - period: string; + period: 'day' | 'week' | 'month' | 'year'; freePeriods: number; freeDays: number; expiresAt: string | null; @@ -93,3 +93,9 @@ export type Payment = { paymentDetailsId: number | null, paymentOperation: string }; + +export type GetOfferPayload = { + offerId: string; +}; + +export type GetOffer = CleengRequest;