Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9874c26815 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.82.4-beta.0"
|
||||
".": "3.81.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -5,49 +5,6 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.82.4-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.3-beta.0...v3.82.4-beta.0) (2026-07-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([0c2d319](https://github.com/PicPeak/picpeak/commit/0c2d319fc1ed67843cc60afdcaea5807ea49226f))
|
||||
* **email,ui:** billing emails follow customer language + readable payment-check confirmation ([fcc3e91](https://github.com/PicPeak/picpeak/commit/fcc3e9195d6f63b2dffddfa72a867a3e32325e81))
|
||||
* **email:** sibling billing emails follow customer language too ([c0008be](https://github.com/PicPeak/picpeak/commit/c0008be39bc8a9d354e48ce8d6bd89662bc53ebb))
|
||||
|
||||
## [3.82.3-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.2-beta.0...v3.82.3-beta.0) (2026-07-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([a88da99](https://github.com/PicPeak/picpeak/commit/a88da99c8d35c0c7cb7f96a235e984edad74ac7c))
|
||||
* **branding:** make 'Show logo in hero' a true global toggle with per-event override ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([96fe478](https://github.com/PicPeak/picpeak/commit/96fe478bf87a3350185206b3d6f15133138b995d))
|
||||
* **branding:** unify hero logo SIZE the same way as visibility ([#756](https://github.com/PicPeak/picpeak/issues/756)) ([60b03b1](https://github.com/PicPeak/picpeak/commit/60b03b17287539b3ad5e5d32f4eda8622f0575e4))
|
||||
|
||||
## [3.82.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.1-beta.0...v3.82.2-beta.0) (2026-07-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **og:** broaden social-crawler coverage (Bluesky Cardyb, WeChat-scraper, fediverse, etc.) ([a0a28a4](https://github.com/PicPeak/picpeak/commit/a0a28a47777db9ca9e60a5134c8d86503c060e79))
|
||||
* **og:** route branded short URLs + slideshow links to OG, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([0dffe0c](https://github.com/PicPeak/picpeak/commit/0dffe0ce92339e0608b3ef660e84c31a62f4a98c))
|
||||
* **og:** route branded short URLs + slideshow to OG handler, add Viber ([#699](https://github.com/PicPeak/picpeak/issues/699)) ([a87ad77](https://github.com/PicPeak/picpeak/commit/a87ad77d8d5215c88f5d95cc7aebaa1769938ec0))
|
||||
|
||||
## [3.82.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.0-beta.0...v3.82.1-beta.0) (2026-07-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **invoices:** correct payment-check email template key so dunning email sends ([9a76333](https://github.com/PicPeak/picpeak/commit/9a763337b658299aae0d7c985071c4a775000f99))
|
||||
* **invoices:** correct payment-check email template key so dunning email sends ([3682de1](https://github.com/PicPeak/picpeak/commit/3682de195b46eae692db3ff4a1476b00d3a6e216))
|
||||
|
||||
## [3.82.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.81.0-beta.0...v3.82.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** final community step ([#732](https://github.com/PicPeak/picpeak/issues/732)) + fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([a5f49e3](https://github.com/PicPeak/picpeak/commit/a5f49e32350564ee4d3894f33e9611e9244cc994))
|
||||
* **setup:** final community/thank-you step ([#732](https://github.com/PicPeak/picpeak/issues/732)); fix create-admin button overflow ([#730](https://github.com/PicPeak/picpeak/issues/730)) ([dadaaee](https://github.com/PicPeak/picpeak/commit/dadaaeea7781cb62811256b512003e5c4d6ad95e))
|
||||
|
||||
## [3.81.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.80.0-beta.0...v3.81.0-beta.0) (2026-07-03)
|
||||
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Migration 152: make events.hero_logo_visible NULL-able so NULL means
|
||||
* "inherit the global branding_logo_display_hero setting" (#756).
|
||||
*
|
||||
* Before: hero_logo_visible was `boolean NOT NULL DEFAULT true`, and every
|
||||
* event got a concrete true/false snapshotted at creation. The global
|
||||
* "Show logo in hero section" toggle (branding_logo_display_hero) was only a
|
||||
* creation-time default and never affected existing galleries — so disabling
|
||||
* it did nothing to already-published galleries.
|
||||
*
|
||||
* After: NULL = inherit. gallery read-resolution falls back to the global
|
||||
* setting when the per-event value is NULL, so the global toggle controls
|
||||
* every gallery that hasn't been deliberately overridden per-event.
|
||||
*
|
||||
* Data backfill: NULL out the DEFAULTED `true` rows so they start inheriting
|
||||
* the global. A deliberate per-gallery hide (`false`) is kept — we can't tell a
|
||||
* defaulted-true from a chosen-true, but `false` is almost always a conscious
|
||||
* "hide it here", and nulling it could silently re-show a hidden logo.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP DEFAULT');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible DROP NOT NULL');
|
||||
} else {
|
||||
// SQLite (and others): knex recreates the table without the NOT NULL/default.
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.boolean('hero_logo_visible').nullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
// Existing defaulted-`true` galleries now inherit the global toggle.
|
||||
await knex('events').where('hero_logo_visible', true).update({ hero_logo_visible: null });
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_visible'))) return;
|
||||
// Re-materialise NULLs as the old default (true) before restoring NOT NULL.
|
||||
await knex('events').whereNull('hero_logo_visible').update({ hero_logo_visible: true });
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET DEFAULT true');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_visible SET NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.boolean('hero_logo_visible').notNullable().defaultTo(true).alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Migration 153: make events.hero_logo_size NULL-able so NULL means "inherit
|
||||
* the global branding_logo_size" (#756 follow-up — the size counterpart of 152).
|
||||
*
|
||||
* Before: hero_logo_size was `varchar NOT NULL DEFAULT 'medium'`, snapshotted
|
||||
* from the global branding_logo_size at creation. The two gallery render paths
|
||||
* then disagreed — GalleryLayout read the global size live, while the
|
||||
* hero-header path used the per-event snapshot — so a hero logo could render at
|
||||
* different sizes on different layouts, and changing the global size didn't
|
||||
* update hero-header galleries.
|
||||
*
|
||||
* After: NULL = inherit. gallery read-resolution falls back to
|
||||
* branding_logo_size when the per-event value is NULL, and both render paths
|
||||
* consume that resolved size.
|
||||
*
|
||||
* Data backfill: NULL out ALL existing hero_logo_size so every gallery inherits
|
||||
* the global size going forward. Unlike a boolean we can't tell a defaulted
|
||||
* value from a chosen one — but nulling is the safe choice here: it restores the
|
||||
* live-global behaviour GalleryLayout already had, and the per-event size can be
|
||||
* re-set from the event's edit page.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP DEFAULT');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size DROP NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('hero_logo_size', 20).nullable().alter();
|
||||
});
|
||||
}
|
||||
|
||||
await knex('events').update({ hero_logo_size: null });
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasColumn('events', 'hero_logo_size'))) return;
|
||||
await knex('events').whereNull('hero_logo_size').update({ hero_logo_size: 'medium' });
|
||||
|
||||
const client = (knex.client.config.client || '').toLowerCase();
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
await knex.raw("ALTER TABLE events ALTER COLUMN hero_logo_size SET DEFAULT 'medium'");
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN hero_logo_size SET NOT NULL');
|
||||
} else {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('hero_logo_size', 20).notNullable().defaultTo('medium').alter();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.82.4-beta.0",
|
||||
"version": "3.81.0-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -343,34 +343,12 @@ describe('isSocialCrawler — extended bot coverage (#521)', () => {
|
||||
// 3rd-party preview services used by business-messaging stacks
|
||||
'LinkPreview/1.0',
|
||||
'Slack-ImgProxy/1.0',
|
||||
// Viber + broader crawler set (#699 follow-up)
|
||||
'Mozilla/5.0 (compatible; Viber)',
|
||||
'Mozilla/5.0 (compatible; Bluesky Cardyb/1.1)',
|
||||
'facebookcatalog/1.0',
|
||||
'kakaotalk-scrap/1.0',
|
||||
'Mozilla/5.0 (compatible; Synapse/1.98)',
|
||||
'Rocket.Chat/6.0',
|
||||
];
|
||||
for (const ua of knownBots) {
|
||||
expect(isSocialCrawler(ua)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT match human in-app-browser UAs (our OG response is meta-only, no redirect)', () => {
|
||||
// These share a token with a preview bot but are also sent by real users
|
||||
// browsing inside the app's webview — matching them would serve a human
|
||||
// the bare OG stub. Deliberately excluded; guard against re-adding them.
|
||||
const inAppBrowsers = [
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit MicroMessenger/8.0.0', // WeChat in-app
|
||||
'Mozilla/5.0 (iPhone) AppleWebKit Line/13.0.0', // LINE in-app
|
||||
'Mozilla/5.0 (Linux; Android) Zalo', // Zalo in-app
|
||||
'Mozilla/5.0 (Macintosh) Chrome/120.0 Safari/537.36 boxing', // "XING" substring trap
|
||||
];
|
||||
for (const ua of inAppBrowsers) {
|
||||
expect(isSocialCrawler(ua)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not match a regular browser UA', () => {
|
||||
const browsers = [
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||||
|
||||
@@ -95,7 +95,7 @@ module.exports = (router) => {
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
|
||||
@@ -339,16 +339,8 @@ module.exports = (router) => {
|
||||
|
||||
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
|
||||
const brandingDefaults = await getBrandingDefaults();
|
||||
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly
|
||||
// set it, so the global branding_logo_display_hero toggle keeps
|
||||
// controlling this gallery afterwards (#756). Only an explicit per-event
|
||||
// choice overrides the global.
|
||||
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined
|
||||
? formatBoolean(hero_logo_visible)
|
||||
: null;
|
||||
// NULL = inherit the global branding_logo_size (#756), resolved at read
|
||||
// time. Only an explicit per-event size overrides it.
|
||||
const effectiveHeroLogoSize = req.body.hero_logo_size || null;
|
||||
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible;
|
||||
const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
|
||||
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
|
||||
|
||||
// Inherit "Detect dev tools" from the global Image Security setting unless
|
||||
@@ -433,8 +425,7 @@ module.exports = (router) => {
|
||||
allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'),
|
||||
require_password: formatBoolean(requirePassword),
|
||||
css_template_id: css_template_id || null,
|
||||
// Already formatBoolean-coerced above, or null = inherit global (#756).
|
||||
hero_logo_visible: effectiveHeroLogoVisible,
|
||||
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
|
||||
hero_logo_size: effectiveHeroLogoSize,
|
||||
hero_logo_position: effectiveHeroLogoPosition,
|
||||
header_style: effectiveHeaderStyle || 'standard',
|
||||
@@ -1225,7 +1216,7 @@ module.exports = (router) => {
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
|
||||
// Header style settings (decoupled from layout)
|
||||
body('header_style').optional().isIn(['hero', 'standard', 'banner', 'minimal', 'none']),
|
||||
@@ -1433,13 +1424,9 @@ module.exports = (router) => {
|
||||
updates.expires_at = null;
|
||||
}
|
||||
|
||||
// Format hero logo settings if provided. null = inherit the global
|
||||
// branding_logo_display_hero toggle (#756); only an explicit true/false
|
||||
// is a per-event override.
|
||||
// Format hero logo settings if provided
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
|
||||
updates.hero_logo_visible = updates.hero_logo_visible === null
|
||||
? null
|
||||
: formatBoolean(updates.hero_logo_visible);
|
||||
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
|
||||
}
|
||||
|
||||
// Per-event opt-in for hero-photo OG share image (#474). Coerce so
|
||||
|
||||
@@ -2,21 +2,9 @@ const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getAppSetting } = require('../utils/appSettings');
|
||||
const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
|
||||
// #756: a NULL per-event hero_logo_visible means "inherit the global
|
||||
// branding_logo_display_hero toggle". Only an explicit true/false is a
|
||||
// per-gallery override. `globalDefault` is branding_logo_display_hero
|
||||
// (defaults true when unset).
|
||||
function resolveHeroLogoVisible(perEvent, globalDefault) {
|
||||
if (perEvent === null || perEvent === undefined) {
|
||||
return globalDefault !== false;
|
||||
}
|
||||
return perEvent !== false && perEvent !== 0 && perEvent !== '0';
|
||||
}
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery');
|
||||
@@ -194,8 +182,6 @@ router.get('/:slug/info', async (req, res) => {
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||
|
||||
res.json({
|
||||
event_name: event.event_name,
|
||||
@@ -213,9 +199,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||
hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible),
|
||||
// #756: NULL per-event size inherits the global branding_logo_size.
|
||||
hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
@@ -650,8 +635,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// selection back to source files. Tied to the same toggle as downloads —
|
||||
// one switch controls both surfaces.
|
||||
const useOriginalFilenames = await getUseOriginalFilenames();
|
||||
const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true);
|
||||
const globalLogoSize = await getAppSetting('branding_logo_size', 'medium');
|
||||
|
||||
|
||||
res.json({
|
||||
event: {
|
||||
@@ -670,8 +654,8 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
watermark_text: req.event.watermark_text,
|
||||
enable_devtools_protection: req.event.enable_devtools_protection === true,
|
||||
use_canvas_rendering: req.event.use_canvas_rendering === true,
|
||||
hero_logo_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible),
|
||||
hero_logo_size: req.event.hero_logo_size || globalLogoSize || 'medium',
|
||||
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
|
||||
hero_logo_size: req.event.hero_logo_size || 'medium',
|
||||
hero_logo_position: req.event.hero_logo_position || 'top',
|
||||
hero_logo_url: req.event.hero_logo_url || null,
|
||||
header_style: req.event.header_style || 'standard',
|
||||
|
||||
@@ -726,12 +726,9 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
// Determine recipient language. An explicit `__language` in the email data
|
||||
// wins (CRM/billing emails set it to the customer/invoice language so a
|
||||
// gallery event's language can't override a dunning notice — see #760);
|
||||
// otherwise fall back to the event-first recipient resolution.
|
||||
const language = variables.__language || await getRecipientLanguage(to, variables.eventId || null);
|
||||
|
||||
// Determine recipient language (pass eventId if available in variables)
|
||||
const language = await getRecipientLanguage(to, variables.eventId || null);
|
||||
|
||||
// Process template with variables
|
||||
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
|
||||
|
||||
@@ -786,7 +783,7 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
async function renderQueuedEmail(templateKey, variables = {}, to = '') {
|
||||
const template = await db('email_templates').where('template_key', templateKey).first();
|
||||
if (!template) return null;
|
||||
const language = variables.__language || await getRecipientLanguage(to, variables.eventId || null);
|
||||
const language = await getRecipientLanguage(to, variables.eventId || null);
|
||||
const { subject, htmlBody } = await processTemplate(template, variables, language);
|
||||
return { subject, html: htmlBody };
|
||||
}
|
||||
|
||||
@@ -34,29 +34,7 @@ const SOCIAL_CRAWLER_PATTERNS = [
|
||||
// messaging stacks (Twilio, LinkPreview.net, etc.). Match the
|
||||
// canonical lowercase substring; the /i flag handles case.
|
||||
/LinkPreview/i,
|
||||
/Slack-ImgProxy/i,
|
||||
// Viber's link-preview fetcher — was never detected, so shared links
|
||||
// showed no rich preview in Viber (#699 follow-up). Keep in sync with the
|
||||
// UA list in frontend/nginx.conf.
|
||||
/Viber/i,
|
||||
// Broader crawler coverage (#699 follow-up, from alexvaltchev's field list).
|
||||
// IMPORTANT: only CRAWLER-EXCLUSIVE tokens are added here. Our OG response is
|
||||
// meta-only (no client redirect), so a UA shared with a real human in-app
|
||||
// browser would serve that human the bare stub. That rules out WeChat
|
||||
// (MicroMessenger), LINE (Line/), Zalo, and generic strings like
|
||||
// "InAppBrowser"/"preview"/"unfurl" — deliberately NOT added.
|
||||
/Cardyb/i, // Bluesky's link-card service (the actual fetcher UA)
|
||||
/facebookcatalog/i, // Facebook catalog crawler
|
||||
/Signal/i, // Signal link preview
|
||||
/Misskey/i, // fediverse (server-side preview fetch)
|
||||
/Pleroma/i, // fediverse
|
||||
/Synapse/i, // Matrix homeserver URL preview
|
||||
/Nextcloud/i, // Nextcloud Talk/News link crawler
|
||||
/Rocket\.Chat/i, // Rocket.Chat server preview
|
||||
/kakaotalk-scrap/i, // KakaoTalk's scraper (NOT the in-app browser UA)
|
||||
/Google-PageRenderer/i, // Google Chat previews (not Search)
|
||||
/OdklBot/i, // Odnoklassniki
|
||||
/ZoomBot/i // Zoom Team Chat
|
||||
/Slack-ImgProxy/i
|
||||
];
|
||||
|
||||
function isSocialCrawler(userAgent) {
|
||||
|
||||
@@ -185,8 +185,6 @@ async function queueInvoicePaidAdminNotification({
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email || '',
|
||||
event_name: invoice.event_name || '',
|
||||
// Keep the body language consistent with the locale-formatted amounts.
|
||||
__language: locale,
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
|
||||
paid_amount: formatMajor(paidTotalMinor, invoice.currency, locale),
|
||||
payment_method: paymentMethod || '',
|
||||
@@ -280,16 +278,13 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
|
||||
: null;
|
||||
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, adminContact.email,
|
||||
'invoice_payment_check', {
|
||||
'invoice_payment_check_admin', {
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer?.company_name
|
||||
|| customer?.display_name
|
||||
|| [customer?.first_name, customer?.last_name].filter(Boolean).join(' ')
|
||||
|| customer?.email || '',
|
||||
event_name: invoice.event_name || '',
|
||||
// Keep the body language consistent with the locale the amounts are
|
||||
// formatted in, instead of event-first resolution (admin-facing gate).
|
||||
__language: locale,
|
||||
due_date: formatShortDate(invoice.due_date),
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
|
||||
paid_amount: formatMajor(paidMinor, invoice.currency, locale),
|
||||
|
||||
@@ -141,7 +141,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
const rawDaysOverdue = Math.floor((Date.now() - new Date(invoice.due_date).getTime()) / 86400000);
|
||||
const daysOverdue = Math.max(1, rawDaysOverdue);
|
||||
const templateKey = level === 1 ? 'invoice_reminder_first' : 'invoice_reminder_second';
|
||||
const locale = ctx.locale || customer.preferred_language || invoice.language || 'de';
|
||||
const locale = ctx.locale || invoice.language || 'de';
|
||||
const outstandingMinor = Math.max(0, newTotal - Number(invoice.paid_amount_minor || 0));
|
||||
|
||||
// Attach the (unchanged) original invoice PDF + the new Mahnung.
|
||||
@@ -154,8 +154,6 @@ async function applyReminder(invoice, lineItems, level, adminId) {
|
||||
const { to: reminderTo, cc: reminderCc } = resolveBillingRecipients(customer, invoice.cc_pdf_email);
|
||||
try {
|
||||
await emailProcessor.queueEmail(invoice.event_id || null, reminderTo, templateKey, {
|
||||
// Render in the customer/invoice language, not the gallery event's (#760).
|
||||
__language: locale,
|
||||
invoice_number: invoice.invoice_number,
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
total_amount: formatMajor(invoice.total_amount_minor, invoice.currency, locale),
|
||||
|
||||
@@ -127,9 +127,6 @@ async function sendInvoice(id, adminId) {
|
||||
installment_label: invoice.installment_label || '',
|
||||
installment_index: invoice.installment_index + 1,
|
||||
installment_total: invoice.installment_total,
|
||||
// Send in the customer's language (matches the ctx.locale-formatted amounts
|
||||
// above) rather than the event-first default resolution.
|
||||
__language: ctx.locale,
|
||||
cc: invoiceCc,
|
||||
attachments: [{
|
||||
filename: `${invoice.invoice_number}.pdf`,
|
||||
@@ -371,8 +368,6 @@ async function sendStorno(stornoId, adminId) {
|
||||
original_issue_date: originalRow?.issue_date ? formatShortDate(originalRow.issue_date) : '',
|
||||
customer_name: customer.display_name || customer.first_name || customer.email.split('@')[0],
|
||||
total_amount: formatMajor(Math.abs(storno.total_amount_minor), storno.currency, ctx.locale),
|
||||
// Match the customer's language (as with the ctx.locale-formatted amount).
|
||||
__language: ctx.locale,
|
||||
cc: stornoCc,
|
||||
attachments: [{
|
||||
filename: `${storno.invoice_number}.pdf`,
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB |
+2
-31
@@ -237,25 +237,12 @@ server {
|
||||
# Facebook, Slack, Twitter, etc.) don't run JS, so the SPA's client-side
|
||||
# meta tags never reach them. Route those UAs to backend's /og handler
|
||||
# via internal rewrite; humans fall through to the SPA via try_files.
|
||||
# {0,2} extra path segments so the deeper gallery share shapes match too:
|
||||
# /gallery/<slug> (public / share-token link)
|
||||
# /gallery/<slug>/client-access (client-access; token is in ?query)
|
||||
# /gallery/<slug>/show/<token> (slideshow; token is a PATH segment)
|
||||
# The slideshow shape has TWO extra segments (show + token) — the old
|
||||
# single-segment `(?:/[^/]+)?` never matched it, so slideshow links fell
|
||||
# through to the SPA and got only the generic site-wide OG (#699 follow-up).
|
||||
# NB: the regex is QUOTED because the {0,2} quantifier's braces would
|
||||
# otherwise be parsed as nginx config block delimiters.
|
||||
location ~ "^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+){0,2}/?$" {
|
||||
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
|
||||
# Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in
|
||||
# backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot
|
||||
# and LinkPreview / Slack-ImgProxy added in #521 to catch
|
||||
# business-API preview fetchers that aren't the main WhatsApp app.
|
||||
# Viber + the broader set below added in #699 follow-up. Only
|
||||
# CRAWLER-EXCLUSIVE tokens — the backend OG response is meta-only (no
|
||||
# redirect), so UAs shared with real human in-app browsers (WeChat's
|
||||
# MicroMessenger, LINE's "Line/", Zalo, "InAppBrowser") are NOT added.
|
||||
if ($http_user_agent ~* "(facebookexternalhit|facebookcatalog|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|Cardyb|OpenGraph|LinkPreview|Viber|Signal|Misskey|Pleroma|Synapse|Nextcloud|Rocket\.Chat|kakaotalk-scrap|Google-PageRenderer|OdklBot|ZoomBot)") {
|
||||
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview)") {
|
||||
rewrite ^ /og/gallery/$gallery_slug last;
|
||||
}
|
||||
try_files $uri $uri/ /index.html;
|
||||
@@ -273,22 +260,6 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
|
||||
# Branded URL shortener (#699). The backend `/s/<short_slug>` route both
|
||||
# 302-redirects humans to the target gallery AND server-renders OG for
|
||||
# social crawlers. Without this proxy, `/s/...` fell through to the SPA
|
||||
# (which has no /s/ route) — so branded short links were dead for humans
|
||||
# and crawlers alike. `^~` beats the regex SPA fallback below. The backend
|
||||
# does its own UA detection, so no crawler `if` is needed here.
|
||||
location ^~ /s/ {
|
||||
set $backend_upstream backend;
|
||||
proxy_pass http://$backend_upstream:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.82.4-beta.0",
|
||||
"version": "3.81.0-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -90,13 +90,6 @@ export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
|
||||
[d, mo, y] = [a, b, c];
|
||||
}
|
||||
if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return '';
|
||||
// Reject impossible calendar dates (day 00, month 13, 31 Feb…) — a partial
|
||||
// value mid-backspace like "0/07/2026" is otherwise coerced to "2026-07-00",
|
||||
// which is a valid string but an Invalid Date that crashes date-fns format()
|
||||
// downstream. Round-trip through Date to confirm the components survive.
|
||||
const yy = Number(y), mm = Number(mo), dd = Number(d);
|
||||
const probe = new Date(yy, mm - 1, dd);
|
||||
if (probe.getFullYear() !== yy || probe.getMonth() !== mm - 1 || probe.getDate() !== dd) return '';
|
||||
return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { LocalizedDateInput } from '../LocalizedDateInput';
|
||||
|
||||
// Stub the settings hook so the component doesn't need a QueryClient; falls
|
||||
// back to the default DD.MM.YYYY display format. The parse/validation under
|
||||
// test is separator-independent.
|
||||
vi.mock('../../../hooks/usePublicSettings', () => ({
|
||||
usePublicSettings: () => ({ settings: {} }),
|
||||
}));
|
||||
|
||||
describe('LocalizedDateInput', () => {
|
||||
const renderInput = (value = '2026-07-07') => {
|
||||
const onChange = vi.fn();
|
||||
render(<LocalizedDateInput value={value} onChange={onChange} />);
|
||||
const input = screen.getByDisplayValue('07.07.2026') as HTMLInputElement;
|
||||
return { input, onChange };
|
||||
};
|
||||
|
||||
it('does not commit an impossible date mid-edit (regression: backspacing the day → "2026-07-00" crashed the page)', () => {
|
||||
const { input, onChange } = renderInput();
|
||||
// Backspacing a day digit leaves "0.07.2026" — a syntactically complete but
|
||||
// invalid date. It must NOT propagate (used to coerce to "2026-07-00",
|
||||
// which crashed date-fns format() downstream).
|
||||
fireEvent.change(input, { target: { value: '0.07.2026' } });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
// Nor may an out-of-range calendar date (31 Feb).
|
||||
fireEvent.change(input, { target: { value: '31.02.2026' } });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits a complete, valid date as ISO', () => {
|
||||
const { input, onChange } = renderInput();
|
||||
fireEvent.change(input, { target: { value: '15.08.2026' } });
|
||||
expect(onChange).toHaveBeenCalledWith('2026-08-15');
|
||||
});
|
||||
});
|
||||
@@ -25,15 +25,6 @@ interface GalleryLayoutProps {
|
||||
promo_mode?: 'inherit' | 'custom' | 'off';
|
||||
promo_markdown?: string | null;
|
||||
};
|
||||
// Effective hero-logo visibility for THIS gallery, already resolved by the
|
||||
// backend (per-event override, else the global branding toggle) (#756).
|
||||
// When provided it wins over brandingSettings.logo_display_hero.
|
||||
heroLogoVisible?: boolean;
|
||||
// Effective hero-logo SIZE, resolved the same way (per-event override, else
|
||||
// the global branding_logo_size) (#756). When provided it wins over
|
||||
// brandingSettings.logo_size for the hero logo — so both render paths
|
||||
// (this layout and the hero-header) size the logo identically.
|
||||
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
|
||||
brandingSettings?: {
|
||||
company_name?: string;
|
||||
company_tagline?: string;
|
||||
@@ -117,8 +108,6 @@ const HeaderDownloadButton: React.FC<{
|
||||
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
event,
|
||||
brandingSettings,
|
||||
heroLogoVisible,
|
||||
heroLogoSize,
|
||||
showLogout = false,
|
||||
onLogout,
|
||||
showDownloadAll = false,
|
||||
@@ -168,10 +157,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
|
||||
// Calculate logo size classes based on settings
|
||||
const getLogoDimensions = (context: 'header' | 'hero'): { className: string; style?: React.CSSProperties } => {
|
||||
// #756: the hero logo uses the backend-resolved per-event size (override,
|
||||
// else global) so it matches the hero-header layout; the header logo keeps
|
||||
// the global size.
|
||||
const size = (context === 'hero' && heroLogoSize) ? heroLogoSize : (brandingSettings?.logo_size || 'medium');
|
||||
const size = brandingSettings?.logo_size || 'medium';
|
||||
const maxHeight = brandingSettings?.logo_max_height || 48;
|
||||
|
||||
if (size === 'custom') {
|
||||
@@ -216,9 +202,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
if (context === 'header') {
|
||||
return brandingSettings?.logo_display_header !== false;
|
||||
} else {
|
||||
// #756: prefer the backend-resolved per-event value (override, else
|
||||
// global); fall back to the global branding toggle if not provided.
|
||||
if (heroLogoVisible !== undefined) return heroLogoVisible;
|
||||
return brandingSettings?.logo_display_hero !== false;
|
||||
}
|
||||
};
|
||||
@@ -230,7 +213,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
};
|
||||
|
||||
const headerLogoSize = getLogoDimensions('header');
|
||||
const heroLogoDimensions = getLogoDimensions('hero');
|
||||
const heroLogoSize = getLogoDimensions('hero');
|
||||
|
||||
// Footer overhaul (#441 + #440). All five socials are independent;
|
||||
// empty string = hide just that icon. Per-event promo override:
|
||||
@@ -616,9 +599,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className={`${heroLogoDimensions.className} w-auto object-contain mx-auto`}
|
||||
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
|
||||
style={{
|
||||
...(heroLogoDimensions.style || {}),
|
||||
...(heroLogoSize.style || {}),
|
||||
// Only apply brightness/invert filter to default logo; custom logos display as-is
|
||||
filter: brandLogoUrl
|
||||
? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
|
||||
@@ -820,8 +820,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
promo_markdown: (data?.event as { promo_markdown?: string | null })?.promo_markdown,
|
||||
}}
|
||||
brandingSettings={brandingSettings}
|
||||
heroLogoVisible={data?.event?.hero_logo_visible !== false}
|
||||
heroLogoSize={data?.event?.hero_logo_size || undefined}
|
||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||
showLogout={true}
|
||||
onLogout={logout}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow, isValid } from 'date-fns';
|
||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
|
||||
import { de, enUS, ptBR, fr } from 'date-fns/locale';
|
||||
import { usePublicSettings } from './usePublicSettings';
|
||||
|
||||
@@ -32,11 +32,6 @@ export const useLocalizedDate = () => {
|
||||
|
||||
const format = (date: Date | string, formatStr?: string) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
// date-fns `format` throws RangeError on an Invalid Date, which crashes the
|
||||
// whole page when a call site renders a transient/partial date (e.g. the
|
||||
// event-date field mid-edit). Return '' instead so a bad value degrades to
|
||||
// blank rather than tearing down the tree.
|
||||
if (!isValid(dateObj)) return '';
|
||||
// Use admin-configured date format if available and no format string provided
|
||||
let dateFormat = formatStr;
|
||||
if (!dateFormat && settings?.general_date_format) {
|
||||
@@ -55,7 +50,6 @@ export const useLocalizedDate = () => {
|
||||
|
||||
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
if (!isValid(dateObj)) return '';
|
||||
return dateFnsFormatDistanceToNow(dateObj, { ...options, locale: getLocale() });
|
||||
};
|
||||
|
||||
|
||||
@@ -1110,9 +1110,6 @@
|
||||
"protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering",
|
||||
"heroLogoSettings": "Hero-Logo-Einstellungen",
|
||||
"heroLogoVisible": "Logo im Hero-Bereich anzeigen",
|
||||
"heroLogoInherit": "Branding-Standard verwenden",
|
||||
"heroLogoShow": "Immer anzeigen",
|
||||
"heroLogoHide": "Immer ausblenden",
|
||||
"heroLogoSize": "Logo-Größe",
|
||||
"heroLogoSizeSmall": "Klein",
|
||||
"heroLogoSizeMedium": "Mittel",
|
||||
@@ -4074,7 +4071,7 @@
|
||||
"noEvents": "Noch keinem Event zugewiesen. Fügen Sie diesen Kunden über das Event-Formular hinzu.",
|
||||
"email": "E-Mail",
|
||||
"preferredLanguage": "Bevorzugte Sprache",
|
||||
"preferredLanguageHint": "Steuert die Portal-Sprache, Angebots-/Rechnungs-PDFs sowie Rechnungs-E-Mails (Erinnerungen/Mahnungen). Neue Kunden erben standardmässig die Sprache aus dem Geschäftsprofil ({{lang}}); hier kann pro Kunde überschrieben werden.",
|
||||
"preferredLanguageHint": "Steuert die Portal-Sprache sowie die Sprache von Angebots- und Rechnungs-PDFs. Neue Kunden erben standardmässig die Sprache aus dem Geschäftsprofil ({{lang}}); hier kann pro Kunde überschrieben werden.",
|
||||
"salutation": "Anrede",
|
||||
"salutationNone": "—",
|
||||
"firstName": "Vorname",
|
||||
|
||||
@@ -655,9 +655,6 @@
|
||||
"protectionLevelMaximum": "Maximum - DevTools detection & canvas rendering",
|
||||
"heroLogoSettings": "Hero Logo Settings",
|
||||
"heroLogoVisible": "Display logo in hero section",
|
||||
"heroLogoInherit": "Use branding default",
|
||||
"heroLogoShow": "Always show",
|
||||
"heroLogoHide": "Always hide",
|
||||
"heroLogoSize": "Logo Size",
|
||||
"heroLogoSizeSmall": "Small",
|
||||
"heroLogoSizeMedium": "Medium",
|
||||
@@ -4074,7 +4071,7 @@
|
||||
"noEvents": "Not assigned to any events yet. Add this customer to an event from the event form.",
|
||||
"email": "Email",
|
||||
"preferredLanguage": "Preferred language",
|
||||
"preferredLanguageHint": "Drives portal UI, quote/invoice PDFs, and billing emails (reminders/dunning). New customers default to the business-profile language ({{lang}}); override here per customer.",
|
||||
"preferredLanguageHint": "Drives portal UI and quote/invoice PDF locale. New customers default to the business-profile language ({{lang}}); override here per customer.",
|
||||
"salutation": "Salutation",
|
||||
"salutationNone": "—",
|
||||
"firstName": "First name",
|
||||
|
||||
@@ -360,7 +360,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('customers.detail.preferredLanguageHint',
|
||||
'Drives portal UI, quote/invoice PDFs, and billing emails (reminders/dunning). New customers default to the business-profile language ({{lang}}); override here per customer.',
|
||||
'Drives portal UI and quote/invoice PDF locale. New customers default to the business-profile language ({{lang}}); override here per customer.',
|
||||
{ lang: LOCALE_LABELS[profileDefaultLocale] || profileDefaultLocale.toUpperCase() })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -306,11 +306,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_presigned_download: (event as { allow_presigned_download?: boolean }).allow_presigned_download ?? false,
|
||||
enable_devtools_protection: event.enable_devtools_protection ?? true,
|
||||
use_canvas_rendering: event.use_canvas_rendering ?? false,
|
||||
// Load hero logo settings from event. Preserve null = "inherit global"
|
||||
// (#756) — don't collapse it to true, or saving would snapshot an override.
|
||||
hero_logo_visible: event.hero_logo_visible ?? null,
|
||||
// Preserve null = "inherit global size" (#756) — don't collapse to medium.
|
||||
hero_logo_size: event.hero_logo_size ?? null,
|
||||
// Load hero logo settings from event
|
||||
hero_logo_visible: event.hero_logo_visible ?? true,
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||
|
||||
@@ -589,43 +589,28 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1 flex items-center gap-1">
|
||||
<Image className="w-4 h-4 text-neutral-500 dark:text-neutral-400" />
|
||||
{t('events.heroLogoVisible', 'Display logo in hero section')}
|
||||
</label>
|
||||
<select
|
||||
// Tri-state (#756): "inherit" = null = follow the global
|
||||
// Branding → "Show logo in hero" toggle; show/hide override it
|
||||
// for just this gallery.
|
||||
value={editForm.hero_logo_visible === null || editForm.hero_logo_visible === undefined
|
||||
? 'inherit'
|
||||
: editForm.hero_logo_visible ? 'show' : 'hide'}
|
||||
onChange={(e) => setEditForm(prev => ({
|
||||
...prev,
|
||||
hero_logo_visible: e.target.value === 'inherit' ? null : e.target.value === 'show'
|
||||
}))}
|
||||
className="w-full sm:w-64 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-accent-dark text-sm"
|
||||
>
|
||||
<option value="inherit">{t('events.heroLogoInherit', 'Use branding default')}</option>
|
||||
<option value="show">{t('events.heroLogoShow', 'Always show')}</option>
|
||||
<option value="hide">{t('events.heroLogoHide', 'Always hide')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.hero_logo_visible}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
|
||||
className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500 dark:text-neutral-400" />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
|
||||
</label>
|
||||
|
||||
{editForm.hero_logo_visible !== false && (
|
||||
{editForm.hero_logo_visible && (
|
||||
<>
|
||||
<div className="ml-6">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.heroLogoSize', 'Logo Size')}
|
||||
</label>
|
||||
<select
|
||||
// '' = inherit the global branding logo size (#756).
|
||||
value={editForm.hero_logo_size ?? ''}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value === '' ? null : e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
|
||||
value={editForm.hero_logo_size}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
|
||||
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md shadow-sm focus:ring-primary-500 focus:border-accent-dark text-sm"
|
||||
>
|
||||
<option value="">{t('events.heroLogoInherit', 'Use branding default')}</option>
|
||||
<option value="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
|
||||
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
|
||||
<option value="large">{t('events.heroLogoSizeLarge', 'Large')}</option>
|
||||
|
||||
@@ -24,9 +24,9 @@ export type EditFormState = {
|
||||
allow_presigned_download: boolean;
|
||||
enable_devtools_protection: boolean;
|
||||
use_canvas_rendering: boolean;
|
||||
// Hero logo settings. null = inherit the global branding toggle (#756).
|
||||
hero_logo_visible: boolean | null;
|
||||
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge' | null;
|
||||
// Hero logo settings
|
||||
hero_logo_visible: boolean;
|
||||
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_position: 'top' | 'center' | 'bottom';
|
||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||
hero_image_anchor: string;
|
||||
@@ -72,9 +72,9 @@ export const INITIAL_EDIT_FORM: EditFormState = {
|
||||
allow_presigned_download: false,
|
||||
enable_devtools_protection: true,
|
||||
use_canvas_rendering: false,
|
||||
// Hero logo settings — null = inherit global branding toggle (#756)
|
||||
hero_logo_visible: null,
|
||||
hero_logo_size: null,
|
||||
// Hero logo settings
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor: 'center',
|
||||
|
||||
@@ -373,18 +373,15 @@ const ResultBox: React.FC<{
|
||||
<div
|
||||
className="rounded-lg border p-6"
|
||||
style={{
|
||||
// Theme-adaptive success card — a light-green tint on light surfaces,
|
||||
// a dark-green tint on dark ones (was a hardcoded light-green mix +
|
||||
// dark-green title that went unreadable in dark mode, #759).
|
||||
borderColor: 'color-mix(in srgb, #16a34a 35%, var(--color-surface))',
|
||||
backgroundColor: 'color-mix(in srgb, #16a34a 12%, var(--color-surface))',
|
||||
borderColor: '#bbf7d0',
|
||||
backgroundColor: 'color-mix(in srgb, #dcfce7 50%, var(--color-surface))',
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 mb-3" style={{ color: '#16a34a' }} />
|
||||
<h1 className="text-lg font-bold mb-1" style={{ color: 'var(--color-text)' }}>
|
||||
<h1 className="text-lg font-bold mb-1" style={{ color: '#166534' }}>
|
||||
{t('paymentCheck.result.title', 'Action recorded')}
|
||||
</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text)' }}>
|
||||
<p className="text-sm">
|
||||
{result.applied === 'paid_full' && t('paymentCheck.result.paid',
|
||||
'Invoice {{n}} marked as paid in full.', { n: inv.invoiceNumber })}
|
||||
{result.applied === 'paid_with_skonto' && t('paymentCheck.result.paidSkonto',
|
||||
|
||||
@@ -43,8 +43,8 @@ export interface Event {
|
||||
enable_devtools_protection?: boolean;
|
||||
use_canvas_rendering?: boolean;
|
||||
// Hero logo customization fields
|
||||
hero_logo_visible?: boolean | null;
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null;
|
||||
hero_logo_visible?: boolean;
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_position?: 'top' | 'center' | 'bottom';
|
||||
hero_logo_url?: string | null;
|
||||
// Per-event opt-in for using the hero photo as the social-share
|
||||
@@ -188,8 +188,8 @@ export interface GalleryData {
|
||||
fragmentation_level?: number;
|
||||
overlay_protection?: boolean;
|
||||
// Hero logo customization fields
|
||||
hero_logo_visible?: boolean | null;
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null;
|
||||
hero_logo_visible?: boolean;
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_position?: 'top' | 'center' | 'bottom';
|
||||
hero_logo_url?: string | null;
|
||||
// Header style settings (decoupled from layout)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Reference in New Issue
Block a user