Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e94e440858 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.82.2-beta.0"
|
||||
".": "3.82.4-beta.0"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,24 @@ 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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 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.2-beta.0",
|
||||
"version": "3.82.4-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_size').optional({ nullable: true }).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,8 +339,16 @@ module.exports = (router) => {
|
||||
|
||||
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
|
||||
const brandingDefaults = await getBrandingDefaults();
|
||||
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;
|
||||
// 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 effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
|
||||
|
||||
// Inherit "Detect dev tools" from the global Image Security setting unless
|
||||
@@ -425,7 +433,8 @@ 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,
|
||||
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
|
||||
// Already formatBoolean-coerced above, or null = inherit global (#756).
|
||||
hero_logo_visible: effectiveHeroLogoVisible,
|
||||
hero_logo_size: effectiveHeroLogoSize,
|
||||
hero_logo_position: effectiveHeroLogoPosition,
|
||||
header_style: effectiveHeaderStyle || 'standard',
|
||||
@@ -1216,7 +1225,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().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_size').optional({ nullable: true }).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']),
|
||||
@@ -1424,9 +1433,13 @@ module.exports = (router) => {
|
||||
updates.expires_at = null;
|
||||
}
|
||||
|
||||
// Format hero logo settings if provided
|
||||
// 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.
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
|
||||
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
|
||||
updates.hero_logo_visible = updates.hero_logo_visible === null
|
||||
? null
|
||||
: formatBoolean(updates.hero_logo_visible);
|
||||
}
|
||||
|
||||
// Per-event opt-in for hero-photo OG share image (#474). Coerce so
|
||||
|
||||
@@ -2,9 +2,21 @@ 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');
|
||||
@@ -182,6 +194,8 @@ 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,
|
||||
@@ -199,8 +213,9 @@ 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: 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_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_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null,
|
||||
header_style: event.header_style || 'standard',
|
||||
@@ -635,7 +650,8 @@ 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: {
|
||||
@@ -654,8 +670,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: 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_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible),
|
||||
hero_logo_size: req.event.hero_logo_size || globalLogoSize || '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,9 +726,12 @@ async function sendTemplateEmail(to, templateKey, variables) {
|
||||
throw new Error('Email configuration not found');
|
||||
}
|
||||
|
||||
// Determine recipient language (pass eventId if available in variables)
|
||||
const language = await getRecipientLanguage(to, variables.eventId || null);
|
||||
|
||||
// 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);
|
||||
|
||||
// Process template with variables
|
||||
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
|
||||
|
||||
@@ -783,7 +786,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 = await getRecipientLanguage(to, variables.eventId || null);
|
||||
const language = variables.__language || await getRecipientLanguage(to, variables.eventId || null);
|
||||
const { subject, htmlBody } = await processTemplate(template, variables, language);
|
||||
return { subject, html: htmlBody };
|
||||
}
|
||||
|
||||
@@ -185,6 +185,8 @@ 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 || '',
|
||||
@@ -285,6 +287,9 @@ async function queuePaymentCheckEmail(invoiceId, { skipThrottle = false } = {})
|
||||
|| [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 || invoice.language || 'de';
|
||||
const locale = ctx.locale || customer.preferred_language || 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,6 +154,8 @@ 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,6 +127,9 @@ 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`,
|
||||
@@ -368,6 +371,8 @@ 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`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.82.2-beta.0",
|
||||
"version": "3.82.4-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -90,6 +90,13 @@ 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')}`;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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,6 +25,15 @@ 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;
|
||||
@@ -108,6 +117,8 @@ const HeaderDownloadButton: React.FC<{
|
||||
export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
event,
|
||||
brandingSettings,
|
||||
heroLogoVisible,
|
||||
heroLogoSize,
|
||||
showLogout = false,
|
||||
onLogout,
|
||||
showDownloadAll = false,
|
||||
@@ -157,7 +168,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
|
||||
// Calculate logo size classes based on settings
|
||||
const getLogoDimensions = (context: 'header' | 'hero'): { className: string; style?: React.CSSProperties } => {
|
||||
const size = brandingSettings?.logo_size || 'medium';
|
||||
// #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 maxHeight = brandingSettings?.logo_max_height || 48;
|
||||
|
||||
if (size === 'custom') {
|
||||
@@ -202,6 +216,9 @@ 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;
|
||||
}
|
||||
};
|
||||
@@ -213,7 +230,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
};
|
||||
|
||||
const headerLogoSize = getLogoDimensions('header');
|
||||
const heroLogoSize = getLogoDimensions('hero');
|
||||
const heroLogoDimensions = getLogoDimensions('hero');
|
||||
|
||||
// Footer overhaul (#441 + #440). All five socials are independent;
|
||||
// empty string = hide just that icon. Per-event promo override:
|
||||
@@ -599,9 +616,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
|
||||
className={`${heroLogoDimensions.className} w-auto object-contain mx-auto`}
|
||||
style={{
|
||||
...(heroLogoSize.style || {}),
|
||||
...(heroLogoDimensions.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,6 +820,8 @@ 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 } from 'date-fns';
|
||||
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow, isValid } from 'date-fns';
|
||||
import { de, enUS, ptBR, fr } from 'date-fns/locale';
|
||||
import { usePublicSettings } from './usePublicSettings';
|
||||
|
||||
@@ -32,6 +32,11 @@ 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) {
|
||||
@@ -50,6 +55,7 @@ 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,6 +1110,9 @@
|
||||
"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",
|
||||
@@ -4071,7 +4074,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 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.",
|
||||
"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.",
|
||||
"salutation": "Anrede",
|
||||
"salutationNone": "—",
|
||||
"firstName": "Vorname",
|
||||
|
||||
@@ -655,6 +655,9 @@
|
||||
"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",
|
||||
@@ -4071,7 +4074,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 and quote/invoice PDF locale. New customers default to the business-profile language ({{lang}}); override here per customer.",
|
||||
"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.",
|
||||
"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 and quote/invoice PDF locale. New customers default to the business-profile language ({{lang}}); override here per customer.',
|
||||
'Drives portal UI, quote/invoice PDFs, and billing emails (reminders/dunning). New customers default to the business-profile language ({{lang}}); override here per customer.',
|
||||
{ lang: LOCALE_LABELS[profileDefaultLocale] || profileDefaultLocale.toUpperCase() })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -306,9 +306,11 @@ 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
|
||||
hero_logo_visible: event.hero_logo_visible ?? true,
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
// 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,
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor: event.hero_image_anchor || 'center',
|
||||
|
||||
@@ -589,28 +589,43 @@ export const EventInformationCard: React.FC<EventInformationCardProps> = ({
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{editForm.hero_logo_visible && (
|
||||
{editForm.hero_logo_visible !== false && (
|
||||
<>
|
||||
<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
|
||||
value={editForm.hero_logo_size}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
|
||||
// '' = 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' }))}
|
||||
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
|
||||
hero_logo_visible: boolean;
|
||||
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
// 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_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
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
// Hero logo settings — null = inherit global branding toggle (#756)
|
||||
hero_logo_visible: null,
|
||||
hero_logo_size: null,
|
||||
hero_logo_position: 'top',
|
||||
// Hero image anchor position (#162)
|
||||
hero_image_anchor: 'center',
|
||||
|
||||
@@ -373,15 +373,18 @@ const ResultBox: React.FC<{
|
||||
<div
|
||||
className="rounded-lg border p-6"
|
||||
style={{
|
||||
borderColor: '#bbf7d0',
|
||||
backgroundColor: 'color-mix(in srgb, #dcfce7 50%, var(--color-surface))',
|
||||
// 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))',
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 className="w-10 h-10 mb-3" style={{ color: '#16a34a' }} />
|
||||
<h1 className="text-lg font-bold mb-1" style={{ color: '#166534' }}>
|
||||
<h1 className="text-lg font-bold mb-1" style={{ color: 'var(--color-text)' }}>
|
||||
{t('paymentCheck.result.title', 'Action recorded')}
|
||||
</h1>
|
||||
<p className="text-sm">
|
||||
<p className="text-sm" style={{ color: 'var(--color-text)' }}>
|
||||
{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;
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_visible?: boolean | null;
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null;
|
||||
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;
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_visible?: boolean | null;
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | null;
|
||||
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: 120 KiB |
Reference in New Issue
Block a user