diff --git a/backend/server.js b/backend/server.js index 2c809b9a..69879c89 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) const { generateRobotsTxt } = require('./src/services/robotsTxtService'); app.get('/robots.txt', async (req, res) => { @@ -576,7 +583,16 @@ try { 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) => { res.sendFile(indexPath); }); diff --git a/backend/src/services/galleryOgService.js b/backend/src/services/galleryOgService.js new file mode 100644 index 00000000..135363eb --- /dev/null +++ b/backend/src/services/galleryOgService.js @@ -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, '''); +} + +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 ` + + + + ${t} + + + + + + + + + + + + + + +

${t}

+

${d}

+

View gallery

+ +`; +} + +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 +}; diff --git a/frontend/index.html b/frontend/index.html index e040cabe..620c64c2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + PicPeak - Photo Sharing Platform diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 8b15f7c3..f3bf2e13 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -148,6 +148,29 @@ server { 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/(?[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 location / { try_files $uri $uri/ /index.html; diff --git a/frontend/nginx.dev.conf b/frontend/nginx.dev.conf index 498492b1..07692ea1 100644 --- a/frontend/nginx.dev.conf +++ b/frontend/nginx.dev.conf @@ -27,6 +27,23 @@ server { add_header Content-Type text/plain; } + # Gallery share URLs: route social-crawler UAs to backend OG handler. + location ~ ^/gallery/(?[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 location / { try_files $uri $uri/ /index.html; diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 7273ea4e..a72a727f 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react'; import type { Photo } from '../../types'; @@ -47,6 +47,9 @@ export const PhotoLightbox: React.FC = ({ const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); const [touchDistance, setTouchDistance] = useState(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 [isSmallScreen, setIsSmallScreen] = useState(typeof window !== 'undefined' ? window.innerWidth < 640 : false); const [feedbackSettings, setFeedbackSettings] = useState<{ @@ -362,7 +365,8 @@ export const PhotoLightbox: React.FC = ({ } }; - // 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) => { if (e.touches.length === 2) { const touch1 = e.touches[0]; @@ -372,6 +376,10 @@ export const PhotoLightbox: React.FC = ({ touch2.clientY - touch1.clientY ); 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 = ({ touch2.clientX - touch1.clientX, touch2.clientY - touch1.clientY ); - + const scale = newDistance / touchDistance; const newZoom = Math.max(1, Math.min(3, zoom * scale)); setZoom(newZoom); @@ -391,8 +399,21 @@ export const PhotoLightbox: React.FC = ({ } }; - const handleTouchEnd = () => { + const handleTouchEnd = (e: React.TouchEvent) => { 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 @@ -405,12 +426,16 @@ export const PhotoLightbox: React.FC = ({ return (
- {/* Close button */} + {/* Close button. top respects iOS safe-area (notch) so it doesn't + disappear under the camera/dynamic-island. */} @@ -435,19 +460,25 @@ export const PhotoLightbox: React.FC = ({ ) : 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. */}
-
+

{currentIndex + 1} / {photos.length}

- -
+ +
- {/* Touch/swipe indicators for mobile */} -
+ {/* Touch/swipe hint for mobile. Sits above the bottom toolbar + (which can wrap to two rows when feedback controls are enabled). */} +
Swipe to navigate
diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 7938cf2f..445c73e7 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -431,7 +431,7 @@ export const EventDetailsPage: React.FC = () => { hero_photo_id: event.hero_photo_id || null, customer_name: event.customer_name || '', 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', external_path: event.external_path || '', require_password: normalizeRequirePassword(event.require_password), @@ -1504,6 +1504,19 @@ export const EventDetailsPage: React.FC = () => {
+ {phoneFieldEnabled && ( +
+
+ {t('events.customerPhone', 'Customer Phone')} +
+
+ {event.customer_phone || ( + {t('common.notSet')} + )} +
+
+ )} +
{t('events.created')}
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 83321372..19b6f6a3 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -7,6 +7,7 @@ export interface Event { event_date: string | null; customer_name?: string; customer_email: string; + customer_phone?: string | null; admin_email: string; welcome_message?: string; color_theme?: string;