Compare commits
10 Commits
generic-ap
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 655784c6d9 | |||
| 586bd7c030 | |||
| e589792137 | |||
| 54af21477f | |||
| 259fa7c22a | |||
| 71b6828807 | |||
| c50544d4bd | |||
| d338722880 | |||
| e0169b803d | |||
| d4270ea85f |
@ -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}")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>}
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user