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
10 changes: 9 additions & 1 deletion apps/portal/src/components/pages/magic-link-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,8 @@ export default class MagicLinkPage extends React.Component {
}

renderOTCForm() {
const {action, actionErrorMessage, otcRef} = this.context;
const {action, actionErrorMessage, otcRef, site, sniperLinks} = this.context;
const isSniperLinksEnabled = Boolean(site.labs?.sniperlinks);
const errors = this.state.errors || {};

if (!otcRef) {
Expand Down Expand Up @@ -293,6 +294,13 @@ export default class MagicLinkPage extends React.Component {
retry={isError}
disabled={isRunning}
/>
{isSniperLinksEnabled && sniperLinks ? (
<SniperLinkButton
href={isAndroidChrome(navigator) ? sniperLinks.android : sniperLinks.desktop}
label={t('Open email')}
brandColor={this.context.brandColor}
/>
) : null}
</footer>
</form>
);
Expand Down
3 changes: 2 additions & 1 deletion apps/portal/src/components/pages/signup-page.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ html[dir="rtl"] .gh-portal-back-sitetitle {
justify-content: center;
color: var(--grey4);
font-size: 1.5rem;
margin: 16px 0 0;
margin: 4px 0 0;
}

.gh-portal-signup-message,
Expand Down Expand Up @@ -137,6 +137,7 @@ footer.gh-portal-signin-footer {
position: relative;
padding-top: 24px;
height: unset;
gap: 12px;
}

.gh-portal-content.signup,
Expand Down
10 changes: 4 additions & 6 deletions apps/portal/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,14 @@ function handleTokenUrl() {
}
}

function setup() {
addRootDiv();
handleTokenUrl();
}

function init() {
// const customSiteUrl = getSiteUrl();
const {siteUrl: customSiteUrl, apiKey, apiUrl, siteI18nEnabled, locale, labs} = getSiteData();
const siteUrl = customSiteUrl || window.location.origin;
setup({siteUrl});

addRootDiv();
handleTokenUrl();

ReactDOM.render(
<React.StrictMode>
<App siteUrl={siteUrl} customSiteUrl={customSiteUrl} apiKey={apiKey} apiUrl={apiUrl} siteI18nEnabled={siteI18nEnabled} locale={locale} labs={labs}/>
Expand Down
39 changes: 37 additions & 2 deletions apps/portal/test/signin-flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,17 @@ const setup = async ({site, member = null, labs = {}}) => {
});
});

ghostApi.member.sendMagicLink = vi.fn(() => {
return Promise.resolve('success');
ghostApi.member.sendMagicLink = vi.fn(async ({email}) => {
if (email.endsWith('@test-sniper-link.example')) {
return {
sniperLinks: {
android: 'https://test.example/',
desktop: 'https://test.example/'
}
};
} else {
return {};
}
});

ghostApi.member.getIntegrityToken = vi.fn(() => {
Expand Down Expand Up @@ -223,6 +232,32 @@ describe('Signin', () => {

expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'jamie@example.com');
});

test('with sniper link', async () => {
const {
ghostApi,
emailInput,
popupIframeDocument,
submitButton
} = await setup({
site: {
...FixtureSite.singleTier.basic,
labs: {sniperlinks: true}
}
});

fireEvent.change(emailInput, {target: {value: 'test@test-sniper-link.example'}});

expect(emailInput).toHaveValue('test@test-sniper-link.example');
fireEvent.click(submitButton);

const sniperLinkButton = await within(popupIframeDocument).findByText(/open email/i);
expect(sniperLinkButton).toBeInTheDocument();
expect(sniperLinkButton).toHaveAttribute('href', 'https://test.example/');
expect(sniperLinkButton).toHaveAttribute('target', '_blank');

expectOTCEnabledSendMagicLinkAPICall(ghostApi, 'test@test-sniper-link.example');
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ const {execSync} = require('child_process');
const TINYBIRD_HOST = process.env.TINYBIRD_HOST || 'http://localhost:7181';
const TINYBIRD_DATASOURCE = 'analytics_events';
const TINYBIRD_MV_DATASOURCE = '_mv_hits';
const TINYBIRD_MV_DAILY_PAGES = '_mv_daily_pages';
const DEFAULT_EVENT_COUNT = 10000;
const BATCH_SIZE = 1000; // Events per API request
const BATCH_SIZE = 10000; // Events per API request (Tinybird handles large batches well)
const PARALLEL_BATCHES = 5; // Number of concurrent batch uploads
const DOCKER_VOLUME_NAME = 'ghost-dev_shared-config';

class DockerAnalyticsManager {
Expand Down Expand Up @@ -519,11 +521,13 @@ class DockerAnalyticsManager {

/**
* Send events to Tinybird Events API
* @param {Array} events - Events to send
* @param {boolean} wait - Whether to wait for processing (slower but confirms ingestion)
*/
async sendEventsToTinybird(events) {
async sendEventsToTinybird(events, wait = false) {
const ndjson = events.map(e => JSON.stringify(e)).join('\n');

const url = `${TINYBIRD_HOST}/v0/events?name=${TINYBIRD_DATASOURCE}&wait=true`;
const url = `${TINYBIRD_HOST}/v0/events?name=${TINYBIRD_DATASOURCE}${wait ? '&wait=true' : ''}`;

const response = await fetch(url, {
method: 'POST',
Expand Down Expand Up @@ -684,22 +688,26 @@ class DockerAnalyticsManager {
// Sort events by timestamp
events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());

// Send in batches
console.log(`\nPushing events to Tinybird...`);
// Send in batches (parallel for speed)
console.log(`\nPushing events to Tinybird (batch size: ${BATCH_SIZE}, parallel: ${PARALLEL_BATCHES})...`);
let sentCount = 0;

// Create all batches
const batches = [];
for (let i = 0; i < events.length; i += BATCH_SIZE) {
const batch = events.slice(i, i + BATCH_SIZE);
batches.push(events.slice(i, i + BATCH_SIZE));
}

try {
await this.sendEventsToTinybird(batch);
sentCount += batch.length;
// Send batches in parallel chunks
for (let i = 0; i < batches.length; i += PARALLEL_BATCHES) {
const parallelBatches = batches.slice(i, i + PARALLEL_BATCHES);

if (sentCount % 10000 === 0 || sentCount === events.length) {
console.log(`Sent ${sentCount}/${events.length} events`);
}
try {
await Promise.all(parallelBatches.map(batch => this.sendEventsToTinybird(batch)));
sentCount += parallelBatches.reduce((sum, b) => sum + b.length, 0);
console.log(`Sent ${sentCount}/${events.length} events`);
} catch (error) {
console.error(`Failed to send batch at offset ${i}:`, error.message);
console.error(`Failed to send batch chunk at offset ${i * BATCH_SIZE}:`, error.message);
throw error;
}
}
Expand All @@ -710,7 +718,7 @@ class DockerAnalyticsManager {

/**
* Clear analytics events from Tinybird
* Truncates both the landing datasource and the materialized view
* Truncates the landing datasource and all materialized views
*/
async clearAnalytics() {
console.log(`\nClearing analytics events...`);
Expand All @@ -719,10 +727,18 @@ class DockerAnalyticsManager {
console.log(`Truncating ${TINYBIRD_DATASOURCE}...`);
await this.truncateDatasource(TINYBIRD_DATASOURCE);

// Truncate the materialized view datasource
// Truncate the materialized view datasources
console.log(`Truncating ${TINYBIRD_MV_DATASOURCE}...`);
await this.truncateDatasource(TINYBIRD_MV_DATASOURCE);

// Truncate the daily pages MV (may not exist in older setups)
console.log(`Truncating ${TINYBIRD_MV_DAILY_PAGES}...`);
try {
await this.truncateDatasource(TINYBIRD_MV_DAILY_PAGES);
} catch (error) {
console.log(` ${TINYBIRD_MV_DAILY_PAGES} not found (may not be deployed yet)`);
}

console.log('All analytics data cleared successfully');
return {status: 'ok'};
}
Expand Down
Loading