Skip to content

Commit

Permalink
✨(frontend) handle OrderGroup on product purchase
Browse files Browse the repository at this point in the history
We need to disable purchasing a product when all order groups are
full, and also display multiple purchase button when there are multiple
with seats available.
  • Loading branch information
NathanVss committed Dec 5, 2023
1 parent d6d7762 commit c1c23c8
Show file tree
Hide file tree
Showing 12 changed files with 318 additions and 19 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ Versioning](https://semver.org/spec/v2.0.0.html).
- Add the ability to sign an order's contract.
- Add terms checkbox and specific "sign" button in the Sale Tunnel.
- The CourseRunProductItem disables enrollment if there is a needed signature.
- Handle OrderGroup on product purchase

### Changed

Expand Down
80 changes: 73 additions & 7 deletions src/frontend/js/components/PaymentButton/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,26 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
import {
AddressFactory,
CreditCardFactory,
CredentialOrderWithOneClickPaymentFactory,
CredentialOrderWithPaymentFactory,
CertificateOrderWithOneClickPaymentFactory,
CertificateOrderWithPaymentFactory,
CertificateProductFactory,
CredentialOrderWithOneClickPaymentFactory,
CredentialOrderWithPaymentFactory,
CredentialProductFactory,
CreditCardFactory,
OrderGroupFactory,
CourseLightFactory,
} from 'utils/test/factories/joanie';
import { PAYMENT_SETTINGS } from 'settings';
import type * as Joanie from 'types/Joanie';
import { OrderState, ProductType, Order, Product } from 'types/Joanie';
import {
OrderCredentialCreationPayload,
OrderState,
ProductType,
Order,
Product,
OrderGroup,
} from 'types/Joanie';
import { createTestQueryClient } from 'utils/test/createTestQueryClient';
import JoanieSessionProvider from 'contexts/SessionContext/JoanieSessionProvider';
import { HttpStatusCode } from 'utils/errors/HttpError';
Expand Down Expand Up @@ -56,7 +64,7 @@ describe.each([
},
])(
'PaymentButton for $productType product',
({ ProductFactory, OrderWithOneClickPaymentFactory, OrderWithPaymentFactory }) => {
({ productType, ProductFactory, OrderWithOneClickPaymentFactory, OrderWithPaymentFactory }) => {
let nbApiCalls: number;
const formatPrice = (price: number, currency: string) =>
new Intl.NumberFormat('en', {
Expand All @@ -68,7 +76,8 @@ describe.each([
client = createTestQueryClient({ user: true }),
children,
product,
}: PropsWithChildren<{ client?: QueryClient; product: Product }>) => {
orderGroup,
}: PropsWithChildren<{ client?: QueryClient; product: Product; orderGroup?: OrderGroup }>) => {
const [order, setOrder] = useState<Maybe<Order>>();

const context: SaleTunnelContextType = useMemo(
Expand All @@ -78,8 +87,9 @@ describe.each([
setOrder,
course: CourseLightFactory({ code: '00000' }).one(),
key: `00000+${product.id}`,
orderGroup,
}),
[product, order, setOrder],
[product, order, setOrder, orderGroup],
);

return (
Expand Down Expand Up @@ -749,5 +759,61 @@ describe.each([
// - Payment interface should be displayed.
screen.getByText('Payment interface component');
});

if (productType === ProductType.CREDENTIAL) {
it('should create an order with an order group', async () => {
const product: Joanie.Product = ProductFactory().one();
const orderGroup = OrderGroupFactory().one();
const billingAddress: Joanie.Address = AddressFactory().one();
const handleSuccess = jest.fn();

let createOrderPayload: Maybe<OrderCredentialCreationPayload>;
const { payment_info: paymentInfo, ...order } = OrderWithPaymentFactory().one();
fetchMock
.get(
`https://joanie.test/api/v1.0/orders/?course_code=00000&product_id=${product.id}&state=pending&state=validated&state=submitted`,
[],
)
.post('https://joanie.test/api/v1.0/orders/', (url, { body }) => {
createOrderPayload = JSON.parse(body as any);
return order;
})
.patch(`https://joanie.test/api/v1.0/orders/${order.id}/submit/`, {
paymentInfo,
})
.get(`https://joanie.test/api/v1.0/orders/${order.id}/`, {
...order,
});

render(
<Wrapper
client={createTestQueryClient({ user: true })}
product={product}
orderGroup={orderGroup}
>
<PaymentButton billingAddress={billingAddress} onSuccess={handleSuccess} />
</Wrapper>,
);

const $button = screen.getByRole('button', {
name: `Pay ${formatPrice(product.price, product.price_currency)}`,
}) as HTMLButtonElement;

const $terms = screen.getByLabelText('By checking this box, you accept the');
await act(async () => {
fireEvent.click($terms);
});

// - Payment button should not be disabled.
expect($button.disabled).toBe(false);

// - User clicks on pay button
await act(async () => {
fireEvent.click($button);
});

await waitFor(() => expect(createOrderPayload?.order_group_id).toEqual(orderGroup.id));
});
}
},
);
3 changes: 2 additions & 1 deletion src/frontend/js/components/PaymentButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const PaymentButton = ({ billingAddress, creditCard, onSuccess }: PaymentButtonP
const intl = useIntl();
const API = useJoanieApi();
const timeoutRef = useRef<NodeJS.Timeout>();
const { course, key, enrollment, product, setOrder } = useSaleTunnelContext();
const { course, key, enrollment, product, setOrder, orderGroup } = useSaleTunnelContext();
const { item: order } = useProductOrder({ courseCode: course.code, productId: product.id });
const orderManager = useOmniscientOrders();
const [payment, setPayment] = useState<PaymentInfo | OneClickPaymentInfo>();
Expand Down Expand Up @@ -201,6 +201,7 @@ const PaymentButton = ({ billingAddress, creditCard, onSuccess }: PaymentButtonP
: {
product_id: product.id,
course_code: course.code,
...(orderGroup ? { order_group_id: orderGroup.id } : {}),
};

orderManager.methods.create(payload, {
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/js/components/PurchaseButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ const messages = defineMessages({
interface PurchaseButtonProps {
product: Joanie.Product;
course: Joanie.CourseLight;
// If the product is a credential, the orderGroup can be required.
orderGroup?: Joanie.OrderGroup;
enrollment?: Joanie.Enrollment;
disabled?: boolean;
className?: string;
Expand All @@ -51,6 +53,7 @@ const PurchaseButton = ({
product,
course,
enrollment,
orderGroup,
disabled = false,
className,
}: PurchaseButtonProps) => {
Expand Down Expand Up @@ -128,6 +131,7 @@ const PurchaseButton = ({
isOpen={isSaleTunnelOpen}
product={product}
enrollment={enrollment}
orderGroup={orderGroup}
course={course}
onClose={() => setIsSaleTunnelOpen(false)}
/>
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/js/components/SaleTunnel/context.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createContext, useContext } from 'react';
import { CourseLight, Order, Product, Enrollment } from 'types/Joanie';
import { CourseLight, Order, OrderGroup, Product, Enrollment } from 'types/Joanie';

export interface SaleTunnelContextType {
product: Product;
orderGroup?: OrderGroup;
order?: Order;
enrollment?: Enrollment;
setOrder: (order: Order) => void;
Expand Down
15 changes: 12 additions & 3 deletions src/frontend/js/components/SaleTunnel/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Modal } from 'components/Modal';
import { CourseLight, Order, Product } from 'types/Joanie';
import { CourseLight, Order, Product, OrderGroup } from 'types/Joanie';
import { useOmniscientOrders, useOrders } from 'hooks/useOrders';
import { IconTypeEnum } from 'components/Icon';
import WebAnalyticsAPIHandler from 'api/web-analytics';
Expand Down Expand Up @@ -46,11 +46,19 @@ type Props = {
product: Product;
course: CourseLight;
enrollment?: Joanie.Enrollment;
orderGroup?: OrderGroup;
isOpen: boolean;
onClose: () => void;
};

const SaleTunnel = ({ product, course, isOpen = false, onClose, enrollment }: Props) => {
const SaleTunnel = ({
product,
course,
orderGroup,
isOpen = false,
onClose,
enrollment,
}: Props) => {
const intl = useIntl();
const {
methods: { refetch: refetchOmniscientOrders },
Expand Down Expand Up @@ -118,8 +126,9 @@ const SaleTunnel = ({ product, course, isOpen = false, onClose, enrollment }: Pr
key,
course,
enrollment,
orderGroup,
}),
[product, order, setOrder, key, course, enrollment],
[product, order, setOrder, key, course, enrollment, orderGroup],
);

/**
Expand Down
10 changes: 7 additions & 3 deletions src/frontend/js/types/Joanie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface CourseProductRelation {
organizations: Organization[];
product: Product;
created_on: string;
order_groups: OrderGroup[];
}
export function isCourseProductRelation(
entity: CourseListItem | CourseProductRelation | RichieCourse,
Expand Down Expand Up @@ -329,12 +330,15 @@ export interface AddressCreationPayload extends Omit<Address, 'id' | 'is_main'>
is_main?: boolean;
}

interface OrderProductCertificateCreationPayload {
interface OrderProductCreationPayload {
product_id: Product['id'];
order_group_id?: OrderGroup['id'];
}

interface OrderProductCertificateCreationPayload extends OrderProductCreationPayload {
enrollment_id: Enrollment['id'];
}
interface OrderCredentialCreationPayload {
product_id: Product['id'];
export interface OrderCredentialCreationPayload extends OrderProductCreationPayload {
course_code: CourseLight['code'];
}

Expand Down
6 changes: 5 additions & 1 deletion src/frontend/js/utils/ProductHelper/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { IntlShape } from 'react-intl';
import { Product, TargetCourse } from 'types/Joanie';
import { CourseProductRelation, Product, TargetCourse } from 'types/Joanie';
import { Maybe } from 'types/utils';
import { IntlHelper } from 'utils/IntlHelper';

Expand Down Expand Up @@ -41,4 +41,8 @@ export class ProductHelper {

return IntlHelper.getLocalizedLanguages(uniqueLanguages, intl);
}

static getActiveOrderGroups(courseProductRelation: CourseProductRelation) {
return courseProductRelation.order_groups?.filter((orderGroup) => orderGroup.is_active);
}
}
21 changes: 21 additions & 0 deletions src/frontend/js/utils/test/factories/joanie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
CertificateOrderWithPaymentInfo,
CredentialOrderWithPaymentInfo,
EnrollmentLight,
OrderGroup,
} from 'types/Joanie';
import { CourseStateFactory } from 'utils/test/factories/richie';
import { FactoryHelper } from 'utils/test/factories/helper';
Expand Down Expand Up @@ -229,13 +230,33 @@ export const CourseLightFactory = factory((): CourseLight => {
};
});

export const OrderGroupFactory = factory((): OrderGroup => {
const seats = faker.number.int({ min: 5, max: 100 });
return {
id: faker.string.uuid(),
is_active: true,
nb_seats: seats,
nb_available_seats: faker.number.int({ min: 2, max: seats }),
};
});

export const OrderGroupFullFactory = factory((): OrderGroup => {
return {
id: faker.string.uuid(),
is_active: true,
nb_seats: faker.number.int({ min: 5, max: 100 }),
nb_available_seats: 0,
};
});

export const CourseProductRelationFactory = factory((): CourseProductRelation => {
return {
id: faker.string.uuid(),
created_on: faker.date.past().toISOString(),
course: CourseFactory().one(),
product: ProductFactory().one(),
organizations: OrganizationFactory().many(1),
order_groups: [],
};
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,25 @@

&__footer {
padding: 0 0.875rem 1rem 0.875rem;
display: flex;
flex-direction: column;
gap: 0.5rem;

.purchase-button__cta {
width: 100%;
}

&__message {
text-align: center;
}

&__order-group {
text-align: center;

p {
margin-top: 0.25rem;
}
}
}

// Compact variant
Expand Down
Loading

0 comments on commit c1c23c8

Please sign in to comment.