Skip to content
Open
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
34 changes: 1 addition & 33 deletions src/components/PurchaseSummary/PurchaseSummary.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Card, Stack } from '@openedx/paragon';
import React, { useEffect, useRef, useState } from 'react';
import React from 'react';

import { usePurchaseSummaryPricing } from '@/components/app/data';
import useTestimonials from '@/components/app/data/hooks/useTestimonials';
import { DataStoreKey } from '@/constants/checkout';
import { useCheckoutFormStore } from '@/hooks/index';

Expand All @@ -12,7 +11,6 @@ import LicensesRow from './LicensesRow';
import PricePerUserRow from './PricePerUserRow';
import PurchaseSummaryCardButton from './PurchaseSummaryCardButton';
import PurchaseSummaryHeader from './PurchaseSummaryHeader';
import TestimonialCard, { Testimonial } from './TestimonialCard';
import TotalAfterTrialRow from './TotalAfterTrialRow';

const PurchaseSummary = () => {
Expand All @@ -31,32 +29,6 @@ const PurchaseSummary = () => {

const normalizedQuantity = parseInt(quantity, 10) === 0 ? null : quantity;

// ✅ Move testimonials API call to client hook
const { data: testimonials = [] } = useTestimonials();
const [currentTestimonial, setCurrentTestimonial] = useState<Testimonial | null>(null);
const shownTestimonialsRef = useRef<string[]>([]);

useEffect(() => {
if (!testimonials.length) { return; }

let available = testimonials.filter(
(t) => t.uuid && !shownTestimonialsRef.current.includes(t.uuid),
);

if (available.length === 0) {
available = testimonials;
shownTestimonialsRef.current = [];
}

const random = available[Math.floor(Math.random() * available.length)];

setCurrentTestimonial(random);

if (random.uuid) {
shownTestimonialsRef.current = [...shownTestimonialsRef.current, random.uuid];
}
}, [testimonials]);

return (
<Card>
<PurchaseSummaryHeader companyName={companyName} />
Expand All @@ -79,10 +51,6 @@ const PurchaseSummary = () => {
/>

<DueTodayRow amountDue={0} />

{currentTestimonial && (
<TestimonialCard testimonial={currentTestimonial} />
)}
</Stack>
</Card.Section>

Expand Down
42 changes: 21 additions & 21 deletions src/components/PurchaseSummary/TestimonialCard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Card, Stack } from '@openedx/paragon';
import React from 'react';

export interface Testimonial {
uuid?: string;
quote_text: string;
attribution_name: string;
attribution_title: string;
is_active?: boolean;
}

interface Props {
Expand All @@ -24,29 +24,29 @@ const TestimonialCard = ({ testimonial }: Props) => {
} = testimonial;

return (
<Card className="mt-4 border-light" data-testid="testimonial-card">
<Card.Body>
<Stack gap={2}>
<div className="h3" aria-hidden="true">
</div>

<blockquote className="mb-0" data-testid="testimonial-quote">
{quoteText}
</blockquote>

<div className="mt-2">
<strong data-testid="testimonial-name">
<div className="testimonial-card mt-4" data-testid="testimonial-card">
<div className="testimonial-card__body">
<div className="testimonial-card__quote-icon" aria-hidden="true">
<span className="testimonial-card__quote-glyph">&#10077;</span>
</div>

<p className="testimonial-card__quote mt-4" data-testid="testimonial-quote">
{quoteText}
</p>
Comment thread
gshivajibiradar marked this conversation as resolved.
Comment thread
gshivajibiradar marked this conversation as resolved.

<div className="testimonial-card__attribution mt-5">
<span className="testimonial-card__attribution-dash" aria-hidden="true">&#8212;</span>
<div className="testimonial-card__attribution-copy">
<span className="testimonial-card__name" data-testid="testimonial-name">
{attributionName}
</strong>

<div className="text-muted" data-testid="testimonial-title">
</span>
<span className="testimonial-card__title d-block" data-testid="testimonial-title">
{attributionTitle}
</div>
</span>
</div>
</Stack>
</Card.Body>
</Card>
</div>
</div>
</div>
);
};

Expand Down
30 changes: 1 addition & 29 deletions src/components/PurchaseSummary/tests/PurchaseSummary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import 'whatwg-fetch';

import { usePurchaseSummaryPricing } from '@/components/app/data';
import { SUBSCRIPTION_TRIAL_LENGTH_DAYS } from '@/components/app/data/constants';
import useTestimonials from '@/components/app/data/hooks/useTestimonials';
import { DataStoreKey } from '@/constants/checkout';
import { checkoutFormStore } from '@/hooks/useCheckoutFormStore';

Expand All @@ -21,9 +20,6 @@ jest.mock('@/components/app/data', () => ({
useCheckoutIntent: jest.fn(() => ({ data: { id: 123 } })),
}));

// Mock testimonials hook
jest.mock('@/components/app/data/hooks/useTestimonials');

const renderWithProviders = () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
Expand Down Expand Up @@ -52,8 +48,6 @@ describe('PurchaseSummary', () => {
yearlySubscriptionCostForQuantity: 150,
yearlyCostPerSubscriptionPerUser: 50,
});

(useTestimonials as jest.Mock).mockReturnValue({ data: [], isLoading: false });
});

it('renders header and rows with computed values', () => {
Expand All @@ -72,33 +66,11 @@ describe('PurchaseSummary', () => {
expect(screen.getByText('$0')).toBeInTheDocument();
});

it('renders correctly when testimonials are empty', async () => {
it('renders correctly', async () => {
renderWithProviders();

await waitFor(() => {
expect(screen.getByText('Purchase summary')).toBeInTheDocument();
});
});

it('renders correctly when testimonials exist', async () => {
(useTestimonials as jest.Mock).mockReturnValue({
data: [
{
uuid: '123',
quote_text: 'Great product!',
attribution_name: 'John Doe',
attribution_title: 'CEO',
},
],
isLoading: false,
});

renderWithProviders();

await waitFor(() => {
expect(screen.getByText('Great product!')).toBeInTheDocument();
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('CEO')).toBeInTheDocument();
});
});
});
5 changes: 5 additions & 0 deletions src/components/Stepper/CheckoutStepperContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Col, Row, Stack, Stepper } from '@openedx/paragon';
import { ReactElement, useEffect } from 'react';

import { useRotatingTestimonial } from '@/components/app/data/hooks/useTestimonials';
import { PurchaseSummary } from '@/components/PurchaseSummary';
import TestimonialCard from '@/components/PurchaseSummary/TestimonialCard';
import { StepperTitle } from '@/components/Stepper/StepperTitle';
import { AccountDetails, BillingDetails, PlanDetails } from '@/components/Stepper/Steps';
import { CheckoutSubstepKey } from '@/constants/checkout';
Expand All @@ -17,6 +19,8 @@ const Steps = (): ReactElement => (

const CheckoutStepperContainer = (): ReactElement => {
const { currentStepKey, currentSubstepKey } = useCurrentStep();
const currentTestimonial = useRotatingTestimonial(currentStepKey ?? 'checkout-step');

useEffect(() => {
const preventUnload = (e: BeforeUnloadEvent) => {
if (currentSubstepKey !== CheckoutSubstepKey.Success) {
Expand Down Expand Up @@ -47,6 +51,7 @@ const CheckoutStepperContainer = (): ReactElement => {
</Col>
<Col md={12} lg={4}>
<PurchaseSummary />
<TestimonialCard testimonial={currentTestimonial} />
</Col>
</Row>
</Stack>
Expand Down
158 changes: 158 additions & 0 deletions src/components/app/data/hooks/tests/useTestimonials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform/config';
import axios from 'axios';

import {
fetchTestimonials,
pickNextTestimonial,
SEEDED_ACTIVE_TESTIMONIALS,
} from '../useTestimonials';

jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
getAuthenticatedUser: jest.fn(),
}));

jest.mock('@edx/frontend-platform/config', () => ({
getConfig: jest.fn(),
}));

jest.mock('axios');

describe('fetchTestimonials', () => {
const mockHttpGet = jest.fn();
const baseUrl = 'https://enterprise-access.example.com';
const endpoint = `${baseUrl}/api/v1/testimonials/`;

beforeEach(() => {
jest.clearAllMocks();
(getConfig as jest.Mock).mockReturnValue({
ENTERPRISE_ACCESS_BASE_URL: baseUrl,
});
(getAuthenticatedHttpClient as jest.Mock).mockReturnValue({
get: mockHttpGet,
});
});

it('uses authenticated client for logged-in users and returns only active testimonials', async () => {
(getAuthenticatedUser as jest.Mock).mockReturnValue({ username: 'test-user' });
mockHttpGet.mockResolvedValue({
data: {
results: [
{
uuid: '1',
quote_text: 'Active quote',
attribution_name: 'Alex',
attribution_title: 'Director',
is_active: true,
},
{
uuid: '2',
quote_text: 'Inactive quote',
attribution_name: 'Pat',
attribution_title: 'Manager',
is_active: false,
},
],
},
});

const result = await fetchTestimonials();

expect(mockHttpGet).toHaveBeenCalledWith(endpoint);
expect((axios.get as jest.Mock)).not.toHaveBeenCalled();
expect(result).toEqual([
{
uuid: '1',
quote_text: 'Active quote',
attribution_name: 'Alex',
attribution_title: 'Director',
is_active: true,
},
]);
});

it('uses axios for logged-out users', async () => {
(getAuthenticatedUser as jest.Mock).mockReturnValue(null);
(axios.get as jest.Mock).mockResolvedValue({
data: {
results: [
{
uuid: '3',
quote_text: 'Public quote',
attribution_name: 'Jordan',
attribution_title: 'Lead',
},
],
},
});

const result = await fetchTestimonials();

expect(axios.get).toHaveBeenCalledWith(endpoint);
expect(mockHttpGet).not.toHaveBeenCalled();
expect(result).toHaveLength(1);
});

it('returns seeded active testimonials when API results are missing or malformed', async () => {
(getAuthenticatedUser as jest.Mock).mockReturnValue(null);
(axios.get as jest.Mock).mockResolvedValue({ data: { results: null } });

const result = await fetchTestimonials();

expect(result).toEqual(SEEDED_ACTIVE_TESTIMONIALS);
});

it('returns seeded active testimonials when request fails', async () => {
(getAuthenticatedUser as jest.Mock).mockReturnValue(null);
(axios.get as jest.Mock).mockRejectedValue(new Error('network'));

const result = await fetchTestimonials();

expect(result).toEqual(SEEDED_ACTIVE_TESTIMONIALS);
});
});

describe('pickNextTestimonial', () => {
const testimonials = [
{
uuid: 'a',
quote_text: 'Quote A',
attribution_name: 'A',
attribution_title: 'Role A',
},
{
uuid: 'b',
quote_text: 'Quote B',
attribution_name: 'B',
attribution_title: 'Role B',
},
{
uuid: 'c',
quote_text: 'Quote C',
attribution_name: 'C',
attribution_title: 'Role C',
},
];

it('does not repeat testimonials until all have been shown', () => {
const shown = new Set<string>();

const first = pickNextTestimonial(testimonials, shown);
const second = pickNextTestimonial(testimonials, shown);
const third = pickNextTestimonial(testimonials, shown);

const uuids = [first?.uuid, second?.uuid, third?.uuid].filter(Boolean);
expect(new Set(uuids).size).toBe(3);
expect(shown.size).toBe(3);
});

it('resets shown set after pool exhaustion and continues rotation', () => {
const shown = new Set<string>(['a', 'b', 'c']);
const next = pickNextTestimonial(testimonials, shown);

expect(next).not.toBeNull();
expect(shown.size).toBe(1);
expect(shown.has(next?.uuid || '')).toBe(true);
});
});
Loading
Loading