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
2 changes: 1 addition & 1 deletion apps/portal/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryghost/portal",
"version": "2.68.36",
"version": "2.68.37",
"license": "MIT",
"repository": "https://github.com/TryGhost/Ghost",
"author": "Ghost Foundation",
Expand Down
44 changes: 36 additions & 8 deletions apps/portal/src/components/pages/gift-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import CloseButton from '../common/close-button';
import ActionButton from '../common/action-button';
import LoadingPage from './loading-page';
import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg';
import giftCardNoiseUrl from '../../images/gift-card-noise.webp';
import giftCardOrbUrl from '../../images/gift-card-orb.webp';
import {getAvailableProducts, getCurrencySymbol, formatNumber, getStripeAmount, isCookiesDisabled, getActiveInterval} from '../../utils/helpers';
import useCardTilt from '../../utils/use-card-tilt';
Expand Down Expand Up @@ -423,11 +424,12 @@ export const GiftPageStyles = `
display: flex;
flex-direction: column;
overflow: hidden;
isolation: isolate;
transform-style: preserve-3d;
will-change: transform;
}

.gh-portal-gift-checkout-card::after {
.gh-portal-gift-checkout-card::before {
content: '';
position: absolute;
top: 0;
Expand All @@ -443,28 +445,41 @@ export const GiftPageStyles = `
opacity: 0.2;
}

.gh-portal-gift-checkout-card::after {
content: '';
position: absolute;
inset: 0;
background-image: url("${giftCardNoiseUrl}");
background-size: 192px 192px;
background-position: 50% 50%;
background-repeat: repeat;
pointer-events: none;
z-index: 2;
opacity: 0.1;
}

.gh-portal-gift-checkout-card > * {
position: relative;
z-index: 1;
}

.gh-portal-gift-checkout-card::before {
content: '';
.gh-portal-gift-checkout-card-notch {
position: absolute;
top: 20px;
left: 50%;
width: 56px;
height: 12px;
border-radius: 12px;
background: rgba(0, 0, 0, 0.35);
background: #000;
background: color-mix(in srgb, var(--brandcolor) 65%, #000 35%);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 0 rgba(255, 255, 255, 0.18);
transform: translateX(-50%);
z-index: 2;
pointer-events: none;
z-index: 3;
}


.gh-portal-gift-checkout-card-meta {
padding: 56px 28px 0;
z-index: 3;
}

.gh-portal-gift-checkout-card-duration {
Expand All @@ -488,6 +503,7 @@ export const GiftPageStyles = `
display: flex;
flex-direction: column;
gap: 8px;
z-index: 3;
}

.gh-portal-gift-checkout-card-detail-label {
Expand All @@ -503,25 +519,36 @@ export const GiftPageStyles = `
}

.gh-portal-gift-checkout-card-site {
background: var(--white);
padding: 16px 28px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}

.gh-portal-gift-checkout-card-site::before {
content: '';
position: absolute;
inset: 0;
background: var(--white);
z-index: 1;
}

.gh-portal-gift-checkout-card-site-icon {
width: 22px;
height: 22px;
object-fit: cover;
position: relative;
z-index: 3;
}

.gh-portal-gift-checkout-card-site-name {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--grey0);
position: relative;
z-index: 3;
}


Expand Down Expand Up @@ -826,6 +853,7 @@ const GiftPage = () => {
<div className='gh-portal-gift-checkout-right-panel'>
<div className='gh-portal-gift-checkout-card-stack'>
<div ref={cardRef} className='gh-portal-gift-checkout-card'>
<div className='gh-portal-gift-checkout-card-notch' aria-hidden='true' />
<div className='gh-portal-gift-checkout-card-meta'>
<div className='gh-portal-gift-checkout-card-duration'>{getDurationLabel(activeInterval)}</div>
<div className='gh-portal-gift-checkout-card-tier'>{`${activeProduct.name} membership`}</div>
Expand Down
1 change: 1 addition & 0 deletions apps/portal/src/components/pages/gift-redemption-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ const GiftRedemptionPage = () => {
<div className='gh-portal-gift-checkout-card-stack' data-revealing={showDetails}>
<div className='gh-portal-gift-checkout-card-frame'>
<div ref={cardRef} className='gh-portal-gift-checkout-card'>
<div className='gh-portal-gift-checkout-card-notch' aria-hidden='true' />
<div className='gh-portal-gift-checkout-card-meta'>
<div className='gh-portal-gift-checkout-card-duration'>{getGiftDurationLabel(gift)}</div>
{/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */}
Expand Down
1 change: 1 addition & 0 deletions apps/portal/src/components/pages/gift-success-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const GiftSuccessPage = () => {
<div className='gh-portal-gift-checkout-card-stack' data-revealing={showDetails}>
<div className='gh-portal-gift-checkout-card-frame'>
<div ref={cardRef} className='gh-portal-gift-checkout-card'>
<div className='gh-portal-gift-checkout-card-notch' aria-hidden='true' />
{tier && cadence && (
<div className='gh-portal-gift-checkout-card-meta'>
<div className='gh-portal-gift-checkout-card-duration'>{getDurationLabel(cadence)}</div>
Expand Down
Binary file added apps/portal/src/images/gift-card-noise.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
72 changes: 70 additions & 2 deletions ghost/core/core/server/web/gift-preview/image.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ const fs = require('fs');
const path = require('path');

const CACHE_MAX_SIZE = 100;
const GIFT_CARD_NOISE_PATH = path.join(__dirname, 'gift-card-noise.png');
const GIFT_CARD_ORB_PATH = path.join(__dirname, 'gift-card-orb.png');
const INTER_FONT_PATH = path.join(__dirname, 'Inter.ttf');

const cache = new Map();
let giftCardNoiseTile;
let giftCardOrbImageHref;

function cacheResult(key, value) {
Expand Down Expand Up @@ -38,6 +40,53 @@ function getGiftCardOrbImageHref() {
return giftCardOrbImageHref;
}

async function getGiftCardNoiseTile() {
if (giftCardNoiseTile !== undefined) {
return giftCardNoiseTile;
}

const sharp = require('sharp');

giftCardNoiseTile = await sharp(GIFT_CARD_NOISE_PATH)
.resize(192, 192, {kernel: 'nearest'})
.png()
.toBuffer();

return giftCardNoiseTile;
}

function parseHexColor(color) {
const match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(`${color || ''}`.trim());

if (!match) {
return null;
}

const hex = match[1].length === 3
? match[1].split('').map(char => char + char).join('')
: match[1];

return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16)
};
}

function formatHexChannel(value) {
return Math.round(value).toString(16).padStart(2, '0');
}

function mixWithBlack(color, colorWeight = 0.65) {
const rgb = parseHexColor(color);

if (!rgb) {
return '#000000';
}

return `#${formatHexChannel(rgb.r * colorWeight)}${formatHexChannel(rgb.g * colorWeight)}${formatHexChannel(rgb.b * colorWeight)}`;
}

function truncateText(str, maxLength) {
const text = `${str || ''}`.trim();

Expand Down Expand Up @@ -120,10 +169,21 @@ function buildSvg({accentColor}) {
<rect width="1200" height="630" fill="#FFFFFF" opacity="0.07"/>
<rect width="1200" height="630" fill="url(#cardShine)"/>
<image href="${orbImageHref}" x="0" y="0" width="1200" height="630" opacity="0.2" preserveAspectRatio="none"/>
</svg>`;
}

<rect x="505" y="42" width="190" height="36" rx="18" fill="#000000" opacity="0.36"/>
function buildNotchOverlay({accentColor}) {
const notchFill = escapeXml(mixWithBlack(accentColor));

const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<rect x="505" y="42" width="190" height="36" rx="18" fill="${notchFill}"/>
<rect x="510" y="44" width="180" height="3" rx="1.5" fill="#FFFFFF" opacity="0.18"/>
</svg>`;

return {
input: Buffer.from(svg)
};
}

async function generateGiftPreviewImage({accentColor = '#15171A', siteTitle, tierLabel, cadenceLabel}) {
Expand All @@ -141,11 +201,19 @@ async function generateGiftPreviewImage({accentColor = '#15171A', siteTitle, tie
const sharp = require('sharp');

const svg = buildSvg({accentColor});
const noiseTile = await getGiftCardNoiseTile();
const image = await sharp(Buffer.from(svg), {animated: false})
.resize(1200, null, {
withoutEnlargement: false
})
.composite(buildTextOverlays({siteTitle, tierLabel, cadenceLabel}))
.composite([
{
input: noiseTile,
tile: true
},
buildNotchOverlay({accentColor}),
...buildTextOverlays({siteTitle, tierLabel, cadenceLabel})
])
.png()
.timeout({seconds: 10})
.toBuffer();
Expand Down
4 changes: 4 additions & 0 deletions ghost/core/test/unit/server/web/gift-preview/image.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ describe('Gift Preview Image', function () {
assert.ok(fs.existsSync(path.join(giftPreviewPath, 'Inter.ttf')));
});

it('bundles the card noise texture for gift preview image rendering', function () {
assert.ok(fs.existsSync(path.join(giftPreviewPath, 'gift-card-noise.png')));
});

it('generates a PNG buffer', async function () {
const result = await imageModule.generateGiftPreviewImage({
accentColor: '#FF5733'
Expand Down
Loading