Merge pull request #338 from the-luap/fix/post-329-bug-triage
fix: mobile lightbox + share previews + customer phone bug triage
This commit is contained in:
+17
-1
@@ -470,6 +470,13 @@ if (process.env.NODE_ENV === 'development') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OG/Twitter-card preview endpoint for gallery share URLs. Crawlers (WhatsApp,
|
||||||
|
// Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags
|
||||||
|
// never reach them. nginx routes UA-detected crawlers from /gallery/:slug to
|
||||||
|
// here; humans still get the SPA via try_files.
|
||||||
|
const { isSocialCrawler, handleGalleryOgRequest } = require('./src/services/galleryOgService');
|
||||||
|
app.get('/og/gallery/:slug', handleGalleryOgRequest);
|
||||||
|
|
||||||
// robots.txt endpoint (dynamic, served from DB settings)
|
// robots.txt endpoint (dynamic, served from DB settings)
|
||||||
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
|
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
|
||||||
app.get('/robots.txt', async (req, res) => {
|
app.get('/robots.txt', async (req, res) => {
|
||||||
@@ -576,7 +583,16 @@ try {
|
|||||||
res.sendFile(indexPath);
|
res.sendFile(indexPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
// SPA fallback for admin + gallery routes
|
// SPA fallback for admin + gallery routes. For gallery URLs we intercept
|
||||||
|
// social-crawler User-Agents and serve OG/Twitter-card metadata so link
|
||||||
|
// previews show the event name + branding instead of the SPA stub.
|
||||||
|
app.get('/gallery/:slug/:token?', (req, res, next) => {
|
||||||
|
if (isSocialCrawler(req.get('user-agent'))) {
|
||||||
|
return handleGalleryOgRequest(req, res);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}, (req, res) => res.sendFile(indexPath));
|
||||||
|
|
||||||
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
|
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
|
||||||
res.sendFile(indexPath);
|
res.sendFile(indexPath);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
const { db } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const SOCIAL_CRAWLER_PATTERNS = [
|
||||||
|
/facebookexternalhit/i,
|
||||||
|
/facebot/i,
|
||||||
|
/Twitterbot/i,
|
||||||
|
/WhatsApp/i,
|
||||||
|
/Slackbot/i,
|
||||||
|
/TelegramBot/i,
|
||||||
|
/SkypeUriPreview/i,
|
||||||
|
/Discordbot/i,
|
||||||
|
/LinkedInBot/i,
|
||||||
|
/Pinterest/i,
|
||||||
|
/vkShare/i,
|
||||||
|
/redditbot/i,
|
||||||
|
/Embedly/i,
|
||||||
|
/iframely/i,
|
||||||
|
/Snapchat/i,
|
||||||
|
/Applebot/i,
|
||||||
|
/quora link preview/i,
|
||||||
|
/Mastodon/i,
|
||||||
|
/Bluesky/i,
|
||||||
|
/OpenGraph/i,
|
||||||
|
/opengraph/i
|
||||||
|
];
|
||||||
|
|
||||||
|
function isSocialCrawler(userAgent) {
|
||||||
|
if (!userAgent) return false;
|
||||||
|
return SOCIAL_CRAWLER_PATTERNS.some((re) => re.test(userAgent));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSettingValue(value) {
|
||||||
|
if (value === null || value === undefined) return null;
|
||||||
|
if (typeof value === 'object') return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBranding() {
|
||||||
|
const rows = await db('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'branding_company_name',
|
||||||
|
'branding_company_tagline',
|
||||||
|
'branding_logo_url'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const branding = { companyName: null, companyTagline: null, logoUrl: null };
|
||||||
|
for (const row of rows) {
|
||||||
|
const parsed = parseSettingValue(row.setting_value);
|
||||||
|
switch (row.setting_key) {
|
||||||
|
case 'branding_company_name':
|
||||||
|
branding.companyName = parsed || null;
|
||||||
|
break;
|
||||||
|
case 'branding_company_tagline':
|
||||||
|
branding.companyTagline = parsed || null;
|
||||||
|
break;
|
||||||
|
case 'branding_logo_url':
|
||||||
|
branding.logoUrl = parsed || null;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return branding;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveSlug(slug) {
|
||||||
|
let event = await db('events').where('slug', slug).first();
|
||||||
|
if (event) return event;
|
||||||
|
// Honour slug redirects so renamed galleries still get rich previews.
|
||||||
|
const hasRedirects = await db.schema.hasTable('event_slug_redirects');
|
||||||
|
if (hasRedirects) {
|
||||||
|
const redirect = await db('event_slug_redirects').where('old_slug', slug).first();
|
||||||
|
if (redirect) {
|
||||||
|
event = await db('events').where('slug', redirect.new_slug).first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return event || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function absoluteUrl(maybeRelative, base) {
|
||||||
|
if (!maybeRelative) return null;
|
||||||
|
if (/^https?:\/\//i.test(maybeRelative)) return maybeRelative;
|
||||||
|
try {
|
||||||
|
return new URL(maybeRelative, base).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function frontendBase() {
|
||||||
|
return (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventDate(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildOgMetadata(slug, requestPath) {
|
||||||
|
const event = await resolveSlug(slug);
|
||||||
|
const branding = await fetchBranding();
|
||||||
|
const base = frontendBase();
|
||||||
|
const siteName = branding.companyName || 'PicPeak';
|
||||||
|
const logoUrl = absoluteUrl(branding.logoUrl, base) || `${base}/picpeak-logo-transparent.png`;
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return {
|
||||||
|
title: siteName,
|
||||||
|
description: branding.companyTagline || 'Photo gallery shared with PicPeak.',
|
||||||
|
image: logoUrl,
|
||||||
|
url: `${base}${requestPath}`,
|
||||||
|
siteName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventName = event.event_name || 'Photo Gallery';
|
||||||
|
const eventDate = formatEventDate(event.event_date);
|
||||||
|
const titleParts = [eventName];
|
||||||
|
if (siteName && siteName !== eventName) titleParts.push(siteName);
|
||||||
|
const title = titleParts.join(' — ');
|
||||||
|
|
||||||
|
let description;
|
||||||
|
if (event.welcome_message) {
|
||||||
|
description = String(event.welcome_message).replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||||
|
} else if (eventDate) {
|
||||||
|
description = `Photo gallery from ${eventName} on ${eventDate}.`;
|
||||||
|
} else {
|
||||||
|
description = `Photo gallery from ${eventName}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
image: logoUrl,
|
||||||
|
url: `${base}/gallery/${event.slug}`,
|
||||||
|
siteName,
|
||||||
|
eventName,
|
||||||
|
eventDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOgHtml(meta) {
|
||||||
|
const t = escapeHtml(meta.title);
|
||||||
|
const d = escapeHtml(meta.description);
|
||||||
|
const i = escapeHtml(meta.image);
|
||||||
|
const u = escapeHtml(meta.url);
|
||||||
|
const s = escapeHtml(meta.siteName);
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>${t}</title>
|
||||||
|
<meta name="description" content="${d}" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="${s}" />
|
||||||
|
<meta property="og:title" content="${t}" />
|
||||||
|
<meta property="og:description" content="${d}" />
|
||||||
|
<meta property="og:url" content="${u}" />
|
||||||
|
<meta property="og:image" content="${i}" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="${t}" />
|
||||||
|
<meta name="twitter:description" content="${d}" />
|
||||||
|
<meta name="twitter:image" content="${i}" />
|
||||||
|
<link rel="canonical" href="${u}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${t}</h1>
|
||||||
|
<p>${d}</p>
|
||||||
|
<p><a href="${u}">View gallery</a></p>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGalleryOgRequest(req, res) {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
if (!slug || !/^[a-zA-Z0-9_-]{1,255}$/.test(slug)) {
|
||||||
|
res.status(400).type('text/plain').send('Invalid gallery slug');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const meta = await buildOgMetadata(slug, req.originalUrl);
|
||||||
|
res.set('Cache-Control', 'public, max-age=300');
|
||||||
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.send(renderOgHtml(meta));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to render gallery OG page', { error: error.message });
|
||||||
|
res.status(500).type('text/plain').send('Internal server error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isSocialCrawler,
|
||||||
|
buildOgMetadata,
|
||||||
|
renderOgHtml,
|
||||||
|
handleGalleryOgRequest
|
||||||
|
};
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>PicPeak - Photo Sharing Platform</title>
|
<title>PicPeak - Photo Sharing Platform</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -148,6 +148,29 @@ server {
|
|||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Social-crawler detection for gallery share URLs. Crawlers (WhatsApp,
|
||||||
|
# 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.
|
||||||
|
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
|
||||||
|
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|Slackbot|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph)") {
|
||||||
|
rewrite ^ /og/gallery/$gallery_slug last;
|
||||||
|
}
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# OG preview endpoint (proxied to backend). Public endpoint by design —
|
||||||
|
# only exposes event_name + branding logo, no protected photo content.
|
||||||
|
location ^~ /og/gallery/ {
|
||||||
|
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 $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -27,6 +27,23 @@ server {
|
|||||||
add_header Content-Type text/plain;
|
add_header Content-Type text/plain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Gallery share URLs: route social-crawler UAs to backend OG handler.
|
||||||
|
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
|
||||||
|
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|Slackbot|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph)") {
|
||||||
|
rewrite ^ /og/gallery/$gallery_slug last;
|
||||||
|
}
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ^~ /og/gallery/ {
|
||||||
|
proxy_pass http://backend: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 $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
@@ -47,6 +47,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
const [touchDistance, setTouchDistance] = useState<number | null>(null);
|
||||||
|
// Ref (not state) so handleTouchEnd reads the value set by handleTouchStart
|
||||||
|
// even when both fire in the same render batch.
|
||||||
|
const swipeStartRef = useRef<{ x: number; y: number; t: number } | null>(null);
|
||||||
const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
|
const [showFeedback, setShowFeedback] = useState(initialShowFeedback);
|
||||||
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
|
const [isSmallScreen, setIsSmallScreen] = useState<boolean>(typeof window !== 'undefined' ? window.innerWidth < 640 : false);
|
||||||
const [feedbackSettings, setFeedbackSettings] = useState<{
|
const [feedbackSettings, setFeedbackSettings] = useState<{
|
||||||
@@ -362,7 +365,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Touch event handlers for pinch-to-zoom
|
// Touch event handlers: pinch-to-zoom (2 fingers) + single-finger swipe nav.
|
||||||
|
// Swipe is suppressed while zoomed in so the user can pan instead.
|
||||||
const handleTouchStart = (e: React.TouchEvent) => {
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
if (e.touches.length === 2) {
|
if (e.touches.length === 2) {
|
||||||
const touch1 = e.touches[0];
|
const touch1 = e.touches[0];
|
||||||
@@ -372,6 +376,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
touch2.clientY - touch1.clientY
|
touch2.clientY - touch1.clientY
|
||||||
);
|
);
|
||||||
setTouchDistance(distance);
|
setTouchDistance(distance);
|
||||||
|
swipeStartRef.current = null;
|
||||||
|
} else if (e.touches.length === 1 && zoom <= 1) {
|
||||||
|
const t = e.touches[0];
|
||||||
|
swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -383,7 +391,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
touch2.clientX - touch1.clientX,
|
touch2.clientX - touch1.clientX,
|
||||||
touch2.clientY - touch1.clientY
|
touch2.clientY - touch1.clientY
|
||||||
);
|
);
|
||||||
|
|
||||||
const scale = newDistance / touchDistance;
|
const scale = newDistance / touchDistance;
|
||||||
const newZoom = Math.max(1, Math.min(3, zoom * scale));
|
const newZoom = Math.max(1, Math.min(3, zoom * scale));
|
||||||
setZoom(newZoom);
|
setZoom(newZoom);
|
||||||
@@ -391,8 +399,21 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||||
setTouchDistance(null);
|
setTouchDistance(null);
|
||||||
|
const start = swipeStartRef.current;
|
||||||
|
if (start && e.changedTouches.length > 0) {
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
const dx = t.clientX - start.x;
|
||||||
|
const dy = t.clientY - start.y;
|
||||||
|
const dt = Date.now() - start.t;
|
||||||
|
// Horizontal swipe: > 50px and dominant over vertical, completed in < 600ms.
|
||||||
|
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.2 && dt < 600) {
|
||||||
|
if (dx > 0) goToPrevious();
|
||||||
|
else goToNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
swipeStartRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply protection class to the lightbox container
|
// Apply protection class to the lightbox container
|
||||||
@@ -405,12 +426,16 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={lightboxClass}>
|
<div className={lightboxClass}>
|
||||||
{/* Close button */}
|
{/* Close button. top respects iOS safe-area (notch) so it doesn't
|
||||||
|
disappear under the camera/dynamic-island. */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-30"
|
className="absolute p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-30"
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
style={{ right: isDesktopFeedback ? `${desktopFeedbackWidth + 16}px` : '1rem' }}
|
style={{
|
||||||
|
top: 'max(1rem, env(safe-area-inset-top))',
|
||||||
|
right: isDesktopFeedback ? `${desktopFeedbackWidth + 16}px` : 'max(1rem, env(safe-area-inset-right))'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="w-6 h-6 text-white" />
|
<X className="w-6 h-6 text-white" />
|
||||||
</button>
|
</button>
|
||||||
@@ -435,19 +460,25 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Bottom toolbar */}
|
{/* Bottom toolbar. flex-wrap + reduced gap/padding on mobile prevent
|
||||||
|
the action row from clipping when feedback (likes / 5-star ratings /
|
||||||
|
comments) is enabled. pb-[env(safe-area-inset-bottom)] keeps the
|
||||||
|
buttons above the iOS home indicator. */}
|
||||||
<div
|
<div
|
||||||
className="absolute bottom-0 left-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20"
|
className="absolute bottom-0 left-0 bg-gradient-to-t from-black/80 to-transparent px-3 pt-3 pb-3 sm:p-4 z-20"
|
||||||
style={{ right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0 }}
|
style={{
|
||||||
|
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
|
||||||
|
paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom))'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
<div className="max-w-4xl mx-auto flex items-center justify-between gap-2 flex-wrap">
|
||||||
<div className="text-white">
|
<div className="text-white">
|
||||||
<p className="text-sm opacity-75">
|
<p className="text-sm opacity-75">
|
||||||
{currentIndex + 1} / {photos.length}
|
{currentIndex + 1} / {photos.length}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={handleZoomOut}
|
onClick={handleZoomOut}
|
||||||
disabled={zoom <= 1}
|
disabled={zoom <= 1}
|
||||||
@@ -617,8 +648,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Touch/swipe indicators for mobile */}
|
{/* Touch/swipe hint for mobile. Sits above the bottom toolbar
|
||||||
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
(which can wrap to two rows when feedback controls are enabled). */}
|
||||||
|
<div className="absolute bottom-40 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
|
||||||
Swipe to navigate
|
Swipe to navigate
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -431,7 +431,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
hero_photo_id: event.hero_photo_id || null,
|
hero_photo_id: event.hero_photo_id || null,
|
||||||
customer_name: event.customer_name || '',
|
customer_name: event.customer_name || '',
|
||||||
customer_email: event.customer_email || '',
|
customer_email: event.customer_email || '',
|
||||||
customer_phone: (event as any).customer_phone || '',
|
customer_phone: event.customer_phone || '',
|
||||||
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||||
external_path: event.external_path || '',
|
external_path: event.external_path || '',
|
||||||
require_password: normalizeRequirePassword(event.require_password),
|
require_password: normalizeRequirePassword(event.require_password),
|
||||||
@@ -1504,6 +1504,19 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{phoneFieldEnabled && (
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t('events.customerPhone', 'Customer Phone')}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-neutral-900 dark:text-neutral-100">
|
||||||
|
{event.customer_phone || (
|
||||||
|
<span className="text-neutral-400">{t('common.notSet')}</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.created')}</dt>
|
<dt className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.created')}</dt>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export interface Event {
|
|||||||
event_date: string | null;
|
event_date: string | null;
|
||||||
customer_name?: string;
|
customer_name?: string;
|
||||||
customer_email: string;
|
customer_email: string;
|
||||||
|
customer_phone?: string | null;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user