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
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ const features: Feature[] = [{
title: 'Sniper Links',
description: 'Enable mail app links on signup/signin',
flag: 'sniperlinks'
}, {
title: 'Paid Breakdown Charts',
description: 'Show paid member change and subscription cadence breakdown charts on the Growth page',
flag: 'paidBreakdownCharts'
}];

const AlphaFeatures: React.FC = () => {
Expand Down
134 changes: 77 additions & 57 deletions apps/portal/src/components/pages/account-plan-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import CloseButton from '../common/close-button';
import BackButton from '../common/back-button';
import {MultipleProductsPlansSection} from '../common/plans-section';
import {getDateString} from '../../utils/date-time';
import {formatNumber, getAvailablePrices, getCurrencySymbol, getFilteredPrices, getMemberActivePrice, getMemberActiveProduct, getMemberSubscription, getPriceFromSubscription, getProductFromPrice, getSubscriptionFromId, getUpgradeProducts, hasMultipleProductsFeature, isComplimentaryMember, isPaidMember} from '../../utils/helpers';
import {formatNumber, getAvailablePrices, getCurrencySymbol, getFilteredPrices, getMemberActivePrice, getMemberActiveProduct, getMemberSubscription, getPriceFromSubscription, getProductFromId, getProductFromPrice, getSubscriptionFromId, getUpdatedOfferPrice, getUpgradeProducts, hasMultipleProductsFeature, isComplimentaryMember, isPaidMember} from '../../utils/helpers';
import Interpolate from '@doist/react-interpolate';
import {t} from '../../utils/i18n';

Expand Down Expand Up @@ -39,35 +39,15 @@ export const AccountPlanPageStyles = `
padding: 6px 12px;
}

.gh-portal-retention-offer {
text-align: center;
.gh-portal-retention-offer-price {
display: flex;
align-items: center;
gap: 6px;
margin-top: 20px;
}

.gh-portal-retention-offer-message {
font-size: 1.5rem;
color: var(--grey4);
margin: 0 0 24px;
line-height: 1.5;
}

.gh-portal-retention-offer-card {
background: var(--grey14);
border-radius: 8px;
padding: 32px 24px;
margin-bottom: 24px;
}

.gh-portal-retention-offer-discount {
font-size: 2.4rem;
font-weight: 700;
color: var(--grey1);
line-height: 1.1;
}

.gh-portal-retention-offer-duration {
font-size: 1.5rem;
color: var(--grey5);
margin-top: 4px;
.gh-portal-retention-offer-price .gh-portal-offer-oldprice {
margin: 4px 0 0;
}
`;

Expand Down Expand Up @@ -295,47 +275,79 @@ function formatOfferDuration(offer) {
return '';
}

const RetentionOfferSection = ({offer, onAcceptOffer, onDeclineOffer}) => {
const RetentionOfferSection = ({offer, product, price, onAcceptOffer, onDeclineOffer}) => {
const {brandColor, action} = useContext(AppContext);
const isAcceptingOffer = action === 'applyOffer:running';

const originalPrice = formatNumber(price.amount / 100);
const discountedPrice = formatNumber(getUpdatedOfferPrice({offer, price}));
const currencySymbol = getCurrencySymbol(price.currency);

const discountText = formatOfferDiscount(offer);
const durationText = formatOfferDuration(offer);
const intervalLabel = offer.cadence === 'month' ? 'month' : 'year';

let offerMessage = `${discountText} ${durationText}.`;

if (offer.duration !== 'forever') {
offerMessage += ` Renews at ${currencySymbol}${originalPrice}/${intervalLabel}.`;
}

// TODO: Add i18n once copy is finalized
return (
<div className="gh-portal-logged-out-form-container gh-portal-retention-offer">
<p className="gh-portal-retention-offer-message">
<div className="gh-portal-logged-out-form-container gh-portal-offer gh-portal-retention-offer">
<p className="gh-portal-text-center">
{'We\'d hate to see you go! How about a special offer to stay?'}
</p>
<div className="gh-portal-retention-offer-card">
<div className="gh-portal-retention-offer-discount">{discountText}</div>
<div className="gh-portal-retention-offer-duration">{durationText}</div>

<div className="gh-portal-offer-bar">
<div className="gh-portal-offer-title">
<h4>{product.name} - {offer.cadence === 'month' ? 'Monthly' : 'Yearly'}</h4>
<h5 className="gh-portal-discount-label">{discountText}</h5>
</div>

<div className="gh-portal-offer-details">
<div className="gh-portal-retention-offer-price">
<div className="gh-portal-product-price">
<span className="currency-sign">{currencySymbol}</span>
<span className="amount">{discountedPrice}</span>
</div>
<div className="gh-portal-offer-oldprice">
{currencySymbol}{originalPrice}
</div>
</div>
<p className="footnote">
{offerMessage}
</p>
</div>

<ActionButton
dataTestId={'accept-retention-offer'}
onClick={onAcceptOffer}
isRunning={isAcceptingOffer}
disabled={isAcceptingOffer}
isPrimary={true}
brandColor={brandColor}
label="Accept offer"
style={{
width: '100%',
height: '40px',
marginTop: '28px'
}}
/>
</div>
<ActionButton
dataTestId={'accept-retention-offer'}
onClick={onAcceptOffer}
isRunning={isAcceptingOffer}
disabled={isAcceptingOffer}
isPrimary={true}
brandColor={brandColor}
label="Accept offer"
style={{
width: '100%',
height: '40px'
}}
/>

<ActionButton
dataTestId={'decline-retention-offer'}
onClick={onDeclineOffer}
isPrimary={false}
isDestructive={true}
classes={'gh-portal-btn-text'}
brandColor={brandColor}
label="Continue to cancellation"
label="No thanks, I want to cancel"
style={{
width: '100%',
marginTop: '24px',
marginTop: '32px',
marginBottom: '24px'
}}
/>
Expand Down Expand Up @@ -382,7 +394,7 @@ const PlansContainer = ({
pendingOffer, onPlanSelect, onPlanCheckout, onConfirm, onCancelSubscription,
onAcceptRetentionOffer, onDeclineRetentionOffer
}) => {
const {member} = useContext(AppContext);
const {member, site} = useContext(AppContext);
// Plan upgrade flow for free member or complimentary member
if (!isPaidMember({member}) || isComplimentaryMember({member})) {
return (
Expand All @@ -404,13 +416,21 @@ const PlansContainer = ({

// Retention offer flow - shown before cancellation confirmation
if (confirmationType === 'offerRetention' && pendingOffer) {
return (
<RetentionOfferSection
offer={pendingOffer}
onAcceptOffer={onAcceptRetentionOffer}
onDeclineOffer={onDeclineRetentionOffer}
/>
);
const offerProduct = getProductFromId({site, productId: pendingOffer.tier.id});
const offerPrice = pendingOffer.cadence === 'month' ? offerProduct?.monthlyPrice : offerProduct?.yearlyPrice;

// Skip retention offer if product or price data is invalid
if (offerProduct && offerPrice) {
return (
<RetentionOfferSection
offer={pendingOffer}
product={offerProduct}
price={offerPrice}
onAcceptOffer={onAcceptRetentionOffer}
onDeclineOffer={onDeclineRetentionOffer}
/>
);
}
}

// Plan confirmation flow for cancel/update flows
Expand Down
4 changes: 2 additions & 2 deletions apps/posts/src/utils/chart-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {STATS_RANGES} from '@src/utils/constants';
export const getPeriodText = (range: number): string => {
const option = Object.values(STATS_RANGES).find(opt => opt.value === range);
if (option) {
if (['Last 7 days', 'Last 30 days', 'Last 3 months', 'Last 12 months'].includes(option.name)) {
if (['Last 7 days', 'Last 30 days', 'Last 90 days', 'Last 12 months'].includes(option.name)) {
return `in the ${option.name.toLowerCase()}`;
}
if (option.name === 'All time') {
Expand All @@ -14,4 +14,4 @@ export const getPeriodText = (range: number): string => {
return option.name.toLowerCase();
}
return '';
};
};
2 changes: 1 addition & 1 deletion apps/posts/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export const STATS_RANGES = {
TODAY: {name: 'Today', value: 1},
LAST_7_DAYS: {name: 'Last 7 days', value: 7},
LAST_30_DAYS: {name: 'Last 30 days', value: 30 + 1},
LAST_3_MONTHS: {name: 'Last 3 months', value: 90 + 1},
LAST_90_DAYS: {name: 'Last 90 days', value: 90 + 1},
YEAR_TO_DATE: {name: 'Year to date', value: 365 + 1},
LAST_12_MONTHS: {name: 'Last 12 months', value: 12 * (30 + 1)},
ALL_TIME: {name: 'All time', value: 1000}
Expand Down
4 changes: 2 additions & 2 deletions apps/posts/test/unit/utils/chart-helpers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('getPeriodText', () => {
it('should return correct text for known ranges', () => {
expect(getPeriodText(STATS_RANGES.LAST_7_DAYS.value)).toBe('in the last 7 days');
expect(getPeriodText(STATS_RANGES.LAST_30_DAYS.value)).toBe('in the last 30 days');
expect(getPeriodText(STATS_RANGES.LAST_3_MONTHS.value)).toBe('in the last 3 months');
expect(getPeriodText(STATS_RANGES.LAST_90_DAYS.value)).toBe('in the last 90 days');
expect(getPeriodText(STATS_RANGES.LAST_12_MONTHS.value)).toBe('in the last 12 months');
expect(getPeriodText(STATS_RANGES.ALL_TIME.value)).toBe('(all time)');
});
Expand All @@ -20,4 +20,4 @@ describe('getPeriodText', () => {
expect(getPeriodText(STATS_RANGES.TODAY.value)).toBe('today');
expect(getPeriodText(STATS_RANGES.YEAR_TO_DATE.value)).toBe('year to date');
});
});
});
8 changes: 6 additions & 2 deletions apps/shade/src/components/ui/gh-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ interface GhAreaChartProps {
showHorizontalLines?: boolean;
dataFormatter?: (value: number) => string;
showHours?: boolean;
tooltipContent?: React.ReactElement;
}

const GhAreaChart: React.FC<GhAreaChartProps> = ({
Expand All @@ -99,7 +100,8 @@ const GhAreaChart: React.FC<GhAreaChartProps> = ({
showYAxisValues = true,
showHorizontalLines = true,
dataFormatter = formatNumber,
showHours = false
showHours = false,
tooltipContent
}) => {
const yRange = yAxisRange || [getYRange(data).min, getYRange(data).max];
const chartConfig = {
Expand Down Expand Up @@ -155,7 +157,7 @@ const GhAreaChart: React.FC<GhAreaChartProps> = ({
width={showYAxisValues ? calculateYAxisWidth(yRange, dataFormatter) : 0}
/>
<ChartTooltip
content={<GhCustomTooltipContent color={color} range={range} showHours={showHours} />}
content={tooltipContent || <GhCustomTooltipContent color={color} range={range} showHours={showHours} />}
cursor={true}
isAnimationActive={false}
position={{y: 10}}
Expand Down Expand Up @@ -193,3 +195,5 @@ export {
GhAreaChart,
GhCustomTooltipContent
};

export type {TooltipProps};
4 changes: 2 additions & 2 deletions apps/stats/src/components/layout/main-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import React from 'react';

const MainLayout: React.FC<React.HTMLAttributes<HTMLDivElement>> = ({children, ...props}) => {
return (
<div className='h-full w-full'>
<div className='relative h-full w-full' {...props}>
<div className='size-full'>
<div className='relative size-full' {...props}>
<div className='mx-auto flex size-full max-w-page flex-col'>
{children}
</div>
Expand Down
20 changes: 20 additions & 0 deletions apps/stats/src/hooks/use-labs-flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {getSettingValue} from '@tryghost/admin-x-framework/api/settings';
import {useGlobalData} from '@src/providers/global-data-provider';

/**
* Simple hook to check if a labs feature flag is enabled
*
* @param flagName The name of the labs feature flag to check
* @returns boolean indicating if the flag is enabled
*/
export const useLabsFlag = (flagName: string): boolean => {
const {settings} = useGlobalData();
const labsJSON = getSettingValue<string>(settings, 'labs') || '{}';

try {
const labs = JSON.parse(labsJSON);
return labs[flagName] === true;
} catch {
return false;
}
};
20 changes: 13 additions & 7 deletions apps/stats/src/utils/chart-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {STATS_RANGE_OPTIONS} from '@src/utils/constants';
export const getPeriodText = (range: number): string => {
const option = STATS_RANGE_OPTIONS.find((opt: {value: number; name: string}) => opt.value === range);
if (option) {
if (['Last 7 days', 'Last 30 days', 'Last 3 months', 'Last 12 months'].includes(option.name)) {
if (['Last 7 days', 'Last 30 days', 'Last 90 days', 'Last 12 months'].includes(option.name)) {
return `in the ${option.name.toLowerCase()}`;
}
if (option.name === 'All time') {
Expand Down Expand Up @@ -71,11 +71,11 @@ function calculateOutlierThreshold(values: number[]): {threshold: number; averag
// Calculate median instead of mean to be more robust against extreme outliers
const sortedValues = [...values].sort((a, b) => a - b);
const median = sortedValues[Math.floor(sortedValues.length / 2)];

// Calculate MAD (Median Absolute Deviation) which is more robust than standard deviation
const deviations = values.map(val => Math.abs(val - median));
const mad = deviations.sort((a, b) => a - b)[Math.floor(deviations.length / 2)];

return {
threshold: median + (5 * mad), // Using 5 times MAD as threshold
average: median
Expand All @@ -85,7 +85,12 @@ function calculateOutlierThreshold(values: number[]): {threshold: number; averag
/**
* Determines the appropriate aggregation strategy based on range and date span
*/
function determineAggregationStrategy(range: number, dateSpan: number, aggregationType: AggregationType): AggregationStrategy {
function determineAggregationStrategy(range: number, dateSpan: number, aggregationType: AggregationType, overrideStrategy?: AggregationStrategy): AggregationStrategy {
// If an override strategy is provided, use it
if (overrideStrategy) {
return overrideStrategy;
}

// Normalize YTD range
if (range === -1) {
if (dateSpan > 150) {
Expand Down Expand Up @@ -260,7 +265,7 @@ function aggregateByMonthExact<T extends {date: string}>(data: T[], fieldName: k
if (isMonthStart || isMonthEnd || isSignificantChange) {
importantPoints.set(item.date, {...item});
}

prevValue = currentValue;
});

Expand All @@ -278,7 +283,8 @@ export const sanitizeChartData = <T extends {date: string}>(
data: T[],
range: number,
fieldName: keyof T = 'value' as keyof T,
aggregationType: AggregationType = 'avg'
aggregationType: AggregationType = 'avg',
overrideStrategy?: AggregationStrategy
): T[] => {
if (!data.length) {
return [];
Expand All @@ -288,7 +294,7 @@ export const sanitizeChartData = <T extends {date: string}>(
const dateSpan = data.length > 1 ? calculateDateSpan(data[0].date, data[data.length - 1].date) : 0;

// Determine aggregation strategy
const strategy = determineAggregationStrategy(range, dateSpan, aggregationType);
const strategy = determineAggregationStrategy(range, dateSpan, aggregationType, overrideStrategy);

// Apply the appropriate aggregation
let result: T[];
Expand Down
2 changes: 1 addition & 1 deletion apps/stats/src/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const STATS_RANGES = {
value: 30 + 1
},
last3Months: {
name: 'Last 3 months',
name: 'Last 90 days',
value: 91
},
yearToDate: {
Expand Down
Loading
Loading