Compare commits

...

10 Commits

10 changed files with 393 additions and 165 deletions

View File

@ -726,12 +726,10 @@ async def send_wedding_invitation_single(
partner1 = event.partner1_name or "" partner1 = event.partner1_name or ""
partner2 = event.partner2_name or "" partner2 = event.partner2_name or ""
# Build guest link (customize per your deployment) # Build guest link as clean /guest/<event_id> path so the frontend
guest_link = ( # regex can reliably extract the event_id from the URL.
event.guest_link or _gl_base = (event.guest_link or "https://invy.dvirlabs.com/guest").split("?")[0].rstrip("/")
f"https://invy.dvirlabs.com/guest?event={event_id}" or guest_link = f"{_gl_base}/{event_id}"
f"https://localhost:5173/guest?event={event_id}"
)
service = get_whatsapp_service() service = get_whatsapp_service()
result = await service.send_wedding_invitation( result = await service.send_wedding_invitation(
@ -840,9 +838,12 @@ async def send_wedding_invitation_bulk(
# Build per-guest link — always unique per event + guest so that # Build per-guest link — always unique per event + guest so that
# a guest invited to multiple events gets a distinct URL each time. # a guest invited to multiple events gets a distinct URL each time.
_base = (event.guest_link or "https://invy.dvirlabs.com/guest").rstrip("/") # Build a clean /guest/<event_id> path URL so the frontend regex
_sep = "&" if "?" in _base else "?" # /^\/guest\/([a-f0-9-]{36})/ can reliably extract the event_id.
per_guest_link = f"{_base}{_sep}event={event_id}&guest_id={guest.id}" _frontend_base = (event.guest_link or "https://invy.dvirlabs.com/guest").rstrip("/")
# Strip any existing ?event= / ?guest_id= to avoid double params
_frontend_base = _frontend_base.split("?")[0].rstrip("/")
per_guest_link = f"{_frontend_base}/{event_id}"
params = { params = {
"contact_name": guest_name, # always auto from guest "contact_name": guest_name, # always auto from guest
@ -1493,6 +1494,32 @@ def _parse_csv_rows(content: bytes) -> list[dict]:
return [dict(row) for row in reader] return [dict(row) for row in reader]
def _parse_xlsx_rows(content: bytes) -> list[dict]:
"""Parse an XLSX (Excel) file and return a list of dicts.
Uses the first sheet; first row is treated as the header.
"""
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
wb.close()
if not rows:
return []
# First row = headers; normalise None headers to empty string
headers = [str(h).strip() if h is not None else "" for h in rows[0]]
result = []
for row in rows[1:]:
# Skip completely empty rows
if all(v is None or str(v).strip() == "" for v in row):
continue
result.append({
headers[i]: (str(v).strip() if v is not None else "")
for i, v in enumerate(row)
if i < len(headers) and headers[i] # skip header-less columns
})
return result
def _parse_json_rows(content: bytes) -> list[dict]: def _parse_json_rows(content: bytes) -> list[dict]:
"""Parse a JSON file — supports array at root OR {data: [...]}.""" """Parse a JSON file — supports array at root OR {data: [...]}."""
payload = json.loads(content.decode("utf-8-sig", errors="replace")) payload = json.loads(content.decode("utf-8-sig", errors="replace"))
@ -1593,15 +1620,19 @@ async def import_contacts(
try: try:
if filename.endswith(".json"): if filename.endswith(".json"):
raw_rows = _parse_json_rows(content) raw_rows = _parse_json_rows(content)
elif filename.endswith(".csv") or filename.endswith(".xlsx"): elif filename.endswith(".xlsx"):
# For XLSX export from our own app, treat as CSV (xlsx export from raw_rows = _parse_xlsx_rows(content)
# GuestList produces proper column headers in English) elif filename.endswith(".csv"):
raw_rows = _parse_csv_rows(content) raw_rows = _parse_csv_rows(content)
else: else:
# Sniff: try JSON then CSV # Sniff: try JSON → xlsx magic bytes → CSV
try: try:
raw_rows = _parse_json_rows(content) raw_rows = _parse_json_rows(content)
except Exception: except Exception:
# XLSX files start with PK (zip magic bytes 50 4B)
if content[:2] == b'PK':
raw_rows = _parse_xlsx_rows(content)
else:
raw_rows = _parse_csv_rows(content) raw_rows = _parse_csv_rows(content)
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=422, detail=f"Cannot parse file: {exc}") raise HTTPException(status_code=422, detail=f"Cannot parse file: {exc}")

View File

@ -6,3 +6,4 @@ pydantic[email]>=2.5.0
httpx>=0.25.2 httpx>=0.25.2
python-dotenv>=1.0.0 python-dotenv>=1.0.0
python-multipart>=0.0.7 python-multipart>=0.0.7
openpyxl>=3.1.2

View File

@ -411,6 +411,123 @@ class WhatsAppService:
parameters=parameters parameters=parameters
) )
async def send_by_template_key(
self,
template_key: str,
to_phone: str,
params: dict,
) -> dict:
"""
Send a WhatsApp template message using the template registry.
Looks up *template_key* in whatsapp_templates.py, resolves header and
body parameter lists (with fallbacks) from *params*, then builds and
sends the Meta API payload dynamically.
Args:
template_key: Registry key (e.g. "wedding_invitation").
to_phone: Recipient phone number (normalized to E.164).
params: Dict of {param_key: value} for all placeholders.
Returns:
dict with message_id and status.
"""
from whatsapp_templates import get_template, build_params_list
tpl = get_template(template_key)
meta_name = tpl["meta_name"]
language_code = tpl.get("language_code", "he")
header_values, body_values = build_params_list(template_key, params)
to_e164 = self.normalize_phone_to_e164(to_phone)
if not self.validate_phone(to_e164):
raise WhatsAppError(f"Invalid phone number: {to_phone}")
components = []
if header_values:
components.append({
"type": "header",
"parameters": [{"type": "text", "text": str(v)} for v in header_values],
})
if body_values:
components.append({
"type": "body",
"parameters": [{"type": "text", "text": str(v)} for v in body_values],
})
# Handle url_button component if defined in template
url_btn = tpl.get("url_button", {})
if url_btn and url_btn.get("enabled"):
param_key = url_btn.get("param_key", "event_id")
btn_value = str(params.get(param_key, "")).strip()
if btn_value:
components.append({
"type": "button",
"sub_type": "url",
"index": str(url_btn.get("button_index", 0)),
"parameters": [{"type": "text", "text": btn_value}],
})
payload = {
"messaging_product": "whatsapp",
"to": to_e164,
"type": "template",
"template": {
"name": meta_name,
"language": {"code": language_code},
"components": components,
},
}
import json
logger.info(
f"[WhatsApp] send_by_template_key '{template_key}' → meta='{meta_name}' "
f"lang={language_code} to={to_e164} "
f"header_params={header_values} body_params={body_values}"
)
logger.debug(
"[WhatsApp] payload: %s",
json.dumps(payload, ensure_ascii=False),
)
url = f"{self.base_url}/{self.phone_number_id}/messages"
try:
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
headers=self.headers,
timeout=30.0,
)
if response.status_code not in (200, 201):
error_data = response.json()
error_msg = error_data.get("error", {}).get("message", "Unknown error")
logger.error(f"[WhatsApp] API error ({response.status_code}): {error_msg}")
raise WhatsAppError(
f"WhatsApp API error ({response.status_code}): {error_msg}"
)
result = response.json()
message_id = result.get("messages", [{}])[0].get("id")
logger.info(f"[WhatsApp] Message sent successfully via template key. ID: {message_id}")
return {
"message_id": message_id,
"status": "sent",
"to": to_e164,
"timestamp": datetime.utcnow().isoformat(),
"type": "template",
"template": meta_name,
}
except httpx.HTTPError as e:
raise WhatsAppError(f"HTTP request failed: {str(e)}")
except WhatsAppError:
raise
except Exception as e:
raise WhatsAppError(f"Failed to send WhatsApp template: {str(e)}")
def handle_webhook_verification(self, challenge: str) -> str: def handle_webhook_verification(self, challenge: str) -> str:
""" """
Handle webhook verification challenge from Meta Handle webhook verification challenge from Meta

View File

@ -5,6 +5,10 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>רשימת אורחים לחתונה</title> <title>רשימת אורחים לחתונה</title>
<!-- Runtime config injected by the Docker entrypoint at container startup.
Populates window.ENV.VITE_API_URL from the VITE_API_URL env var.
MUST be loaded before the main bundle. -->
<script src="/config.js"></script>
</head> </head>
<body dir="rtl"> <body dir="rtl">
<div id="root"></div> <div id="root"></div>

View File

@ -60,9 +60,12 @@ function App() {
return return
} }
// Handle guest self-service mode (legacy no event ID) // Handle guest self-service mode also check ?event= query param (sent in WhatsApp body text)
if (path === '/guest' || path === '/guest/') { if (path === '/guest' || path === '/guest/') {
setRsvpEventId(null) // Try to extract event ID from ?event=<uuid> or ?event_id=<uuid> query param
const eventFromQuery =
params.get('event') || params.get('event_id') || null
setRsvpEventId(eventFromQuery)
setCurrentPage('guest-self-service') setCurrentPage('guest-self-service')
return return
} }

View File

@ -138,21 +138,37 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background: var(--color-background-tertiary);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 0.6rem 0.25rem;
} }
.stat-label { .stat-label {
font-size: 0.8rem; font-size: 0.72rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-transform: uppercase; text-transform: uppercase;
margin-bottom: 0.25rem; letter-spacing: 0.04em;
margin-bottom: 0.2rem;
text-align: center;
} }
.stat-value { .stat-value {
font-size: 1.5rem; font-size: 1.35rem;
font-weight: bold; font-weight: 700;
color: var(--color-primary); color: var(--color-text);
} }
/* per-stat accent colors — !important guards against global .stat-value overrides */
.stat .stat-value--total { color: var(--color-primary) !important; }
.stat .stat-value--confirmed { color: var(--color-success) !important; }
.stat .stat-value--rate { color: var(--color-warning) !important; }
/* per-stat tinted backgrounds */
.stat--total { background: rgba(82, 148, 255, 0.12); border-color: rgba(82, 148, 255, 0.30); }
.stat--confirmed { background: rgba(46, 199, 107, 0.12); border-color: rgba(46, 199, 107, 0.30); }
.stat--rate { background: rgba(245, 166, 35, 0.12); border-color: rgba(245, 166, 35, 0.30); }
.event-card-actions { .event-card-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -176,17 +192,20 @@
.btn-delete { .btn-delete {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: #ecf0f1; background: var(--color-background-tertiary);
border: none; border: 1px solid var(--color-border);
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 1.1rem; font-size: 1.1rem;
transition: background 0.3s ease; color: var(--color-text-secondary);
transition: background 0.2s ease, color 0.2s ease;
} }
.btn-delete:hover { .btn-delete:hover {
background: #e74c3c; background: var(--color-danger);
transform: scale(1.1); border-color: var(--color-danger);
color: #fff;
transform: scale(1.05);
} }
.event-list-loading { .event-list-loading {

View File

@ -139,18 +139,18 @@ function EventList({ onEventSelect, onCreateEvent, onManageTemplates }) {
<p className="event-date">📅 {formatDate(event.date)}</p> <p className="event-date">📅 {formatDate(event.date)}</p>
<div className="event-stats"> <div className="event-stats">
<div className="stat"> <div className="stat stat--total">
<span className="stat-label">{he.guests}</span> <span className="stat-label">{he.guests}</span>
<span className="stat-value">{guestStats.total}</span> <span className="stat-value stat-value--total">{guestStats.total}</span>
</div> </div>
<div className="stat"> <div className="stat stat--confirmed">
<span className="stat-label">{he.confirmed}</span> <span className="stat-label">{he.confirmed}</span>
<span className="stat-value">{guestStats.confirmed}</span> <span className="stat-value stat-value--confirmed">{guestStats.confirmed}</span>
</div> </div>
{guestStats.total > 0 && ( {guestStats.total > 0 && (
<div className="stat"> <div className="stat stat--rate">
<span className="stat-label">{he.rate}</span> <span className="stat-label">{he.rate}</span>
<span className="stat-value"> <span className="stat-value stat-value--rate">
{Math.round((guestStats.confirmed / guestStats.total) * 100)}% {Math.round((guestStats.confirmed / guestStats.total) * 100)}%
</span> </span>
</div> </div>

View File

@ -15,121 +15,156 @@
.guest-list-header { .guest-list-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: 0.75rem;
margin-bottom: 2rem; margin-bottom: 2rem;
gap: 1rem;
flex-wrap: wrap;
} }
[dir="rtl"] .guest-list-header { [dir="rtl"] .guest-list-header-top {
flex-direction: row-reverse; flex-direction: row-reverse;
} }
.btn-back { [dir="rtl"] .guest-list-header-actions {
padding: 0.75rem 1.5rem; flex-direction: row-reverse;
background: var(--color-text-secondary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
transition: background 0.3s ease;
} }
.btn-back:hover { [dir="rtl"] .btn-group {
background: var(--color-text-light); flex-direction: row-reverse;
} }
.guest-list-header h2 { /*
HEADER two-row layout
Row 1: back button + title block
Row 2: secondary tools | primary actions
*/
.guest-list-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 2rem;
}
/* Row 1 */
.guest-list-header-top {
display: flex;
align-items: center;
gap: 1rem;
}
.header-title {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.header-event-title {
margin: 0; margin: 0;
color: var(--color-text); color: var(--color-text);
font-size: 1.8rem; font-size: 1.75rem;
flex: 1; font-weight: 700;
line-height: 1.2;
} }
.header-actions { .header-event-subtitle {
font-size: 0.78rem;
color: var(--color-text-secondary);
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* Row 2 */
.guest-list-header-actions {
display: flex; display: flex;
gap: 1rem; align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap; flex-wrap: wrap;
} }
[dir="rtl"] .header-actions { .btn-group {
flex-direction: row-reverse; display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
} }
.btn-members, /* ── shared button base ── */
.btn-back,
.btn-tool,
.btn-add-guest,
.btn-whatsapp,
.btn-export,
.btn-duplicate {
display: inline-flex;
align-items: center;
gap: 0.35em;
height: 38px;
padding: 0 1rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
}
/* back */
.btn-back {
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.btn-back:hover {
background: var(--color-background-tertiary);
color: var(--color-text);
}
/* secondary tool buttons */
.btn-tool,
.btn-export,
.btn-duplicate {
background: var(--color-background-tertiary);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-tool:hover,
.btn-export:hover,
.btn-duplicate:hover {
background: var(--color-border);
border-color: var(--color-text-secondary);
}
/* primary: add guest */
.btn-add-guest { .btn-add-guest {
padding: 0.75rem 1.5rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-members:hover,
.btn-add-guest:hover {
background: var(--color-primary-hover);
}
.btn-export {
padding: 0.75rem 1.5rem;
background: var(--color-success); background: var(--color-success);
color: white; color: #fff;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
} }
.btn-add-guest:hover {
.btn-export:hover {
background: var(--color-success-hover); background: var(--color-success-hover);
} }
.btn-duplicate { /* whatsapp */
padding: 0.75rem 1.5rem;
background: var(--color-warning);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: background 0.3s ease;
}
.btn-duplicate:hover {
background: var(--color-warning-hover);
}
.btn-whatsapp { .btn-whatsapp {
padding: 0.75rem 1.5rem;
background: #25d366; background: #25d366;
color: white; color: #fff;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
white-space: nowrap;
} }
.btn-whatsapp:hover { .btn-whatsapp:hover {
background: #20ba5e; background: #1eba58;
box-shadow: 0 2px 8px rgba(37, 211, 102, 0.3); box-shadow: 0 2px 8px rgba(37, 211, 102, 0.35);
} }
.btn-whatsapp:disabled { .btn-whatsapp:disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
/* ── legacy class aliases kept for any remaining refs ── */
.header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.btn-members { display: none; }
.pagination-controls { .pagination-controls {
display: flex; display: flex;
gap: 12px; gap: 12px;
@ -446,35 +481,30 @@ td {
background: var(--color-danger-hover); background: var(--color-danger-hover);
} }
/* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.guest-list-header { .guest-list-header-top {
flex-direction: column; flex-wrap: wrap;
align-items: stretch;
}
[dir="rtl"] .guest-list-header {
flex-direction: column-reverse;
} }
.btn-back { .btn-back {
width: 100%; width: 100%;
} }
.guest-list-header h2 { .header-title {
width: 100%; width: 100%;
} }
.header-actions { .guest-list-header-actions {
width: 100%; flex-direction: column;
flex-wrap: wrap; align-items: stretch;
} }
.btn-members, .btn-group {
.btn-add-guest, justify-content: stretch;
.btn-export { }
.btn-group > * {
flex: 1; flex: 1;
min-width: 120px;
} }
.guest-stats { .guest-stats {

View File

@ -193,16 +193,26 @@ function GuestList({ eventId, onBack, onShowMembers }) {
// Apply search and filter logic // Apply search and filter logic
const filteredGuests = guests.filter(guest => { const filteredGuests = guests.filter(guest => {
// Text search - search in name, email, phone // Text search normalize whitespace first, then match token-by-token so that:
// trailing/leading spaces don't break results ("דור " == "דור")
// multiple spaces collapse to one ("דור נחמני" == "דור נחמני")
// full-name search works ("דור נחמני" matches first="דור" last="נחמני")
if (searchFilters.query) { if (searchFilters.query) {
const query = searchFilters.query.toLowerCase() const normalized = searchFilters.query.trim().replace(/\s+/g, ' ')
const matchesQuery = if (normalized === '') {
guest.first_name?.toLowerCase().includes(query) || // After normalization the query is blank treat as "no filter"
guest.last_name?.toLowerCase().includes(query) || } else {
guest.email?.toLowerCase().includes(query) || const tokens = normalized.toLowerCase().split(' ').filter(Boolean)
guest.phone_number?.toLowerCase().includes(query) const haystack = [
guest.first_name || '',
guest.last_name || '',
guest.phone_number|| '',
guest.email || '',
].join(' ').toLowerCase()
const matchesQuery = tokens.every(token => haystack.includes(token))
if (!matchesQuery) return false if (!matchesQuery) return false
} }
}
// RSVP Status filter // RSVP Status filter
if (searchFilters.rsvpStatus && guest.rsvp_status !== searchFilters.rsvpStatus) { if (searchFilters.rsvpStatus && guest.rsvp_status !== searchFilters.rsvpStatus) {
@ -319,20 +329,32 @@ function GuestList({ eventId, onBack, onShowMembers }) {
return ( return (
<div className="guest-list-container"> <div className="guest-list-container">
<div className="guest-list-header"> <div className="guest-list-header">
{/* ── Row 1: back + title ── */}
<div className="guest-list-header-top">
<button className="btn-back" onClick={onBack}>{he.backToEvents}</button> <button className="btn-back" onClick={onBack}>{he.backToEvents}</button>
<h2>{he.guestManagement}</h2> <div className="header-title">
<div className="header-actions"> <h2 className="header-event-title">
{/* <button className="btn-members" onClick={onShowMembers}> {eventData?.name || he.guestManagement}
{he.manageMembers} </h2>
</button> */} {eventData?.name && (
<button className="btn-duplicate" onClick={() => setShowDuplicateManager(true)}> <span className="header-event-subtitle">{he.guestManagement}</span>
🔍 חיפוש כפולויות )}
</div>
</div>
{/* ── Row 2: toolbar ── */}
<div className="guest-list-header-actions">
<div className="btn-group btn-group-tools">
<button className="btn-tool" onClick={() => setShowDuplicateManager(true)}>
🔍 כפולויות
</button> </button>
<GoogleImport eventId={eventId} onImportComplete={loadGuests} /> <GoogleImport eventId={eventId} onImportComplete={loadGuests} />
<ImportContacts eventId={eventId} onImportComplete={loadGuests} /> <ImportContacts eventId={eventId} onImportComplete={loadGuests} />
<button className="btn-export" onClick={exportToExcel}> <button className="btn-tool" onClick={exportToExcel}>
{he.exportExcel} 📥 אקסל
</button> </button>
</div>
<div className="btn-group btn-group-primary">
{selectedGuestIds.size > 0 && ( {selectedGuestIds.size > 0 && (
<button <button
className="btn-whatsapp" className="btn-whatsapp"
@ -350,6 +372,7 @@ function GuestList({ eventId, onBack, onShowMembers }) {
</button> </button>
</div> </div>
</div> </div>
</div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}

View File

@ -118,7 +118,7 @@ function ImportContacts({ eventId, onImportComplete }) {
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept=".csv,.json" accept=".csv,.json,.xlsx"
style={{ display: 'none' }} style={{ display: 'none' }}
onChange={handleFileChange} onChange={handleFileChange}
/> />