diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..06a4918
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+client/node_modules/
+client/dist/
+.env
+.saac/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..18bbe08
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+# Install backend dependencies
+COPY package.json ./
+RUN npm install
+
+# Build frontend
+COPY client/package.json client/
+RUN cd client && npm install
+
+COPY client/ client/
+RUN cd client && npm run build
+
+# Copy server
+COPY server.js ./
+
+EXPOSE 3000
+
+ENV NODE_ENV=production
+ENV PORT=3000
+
+CMD ["node", "server.js"]
diff --git a/client/index.html b/client/index.html
new file mode 100644
index 0000000..322cd59
--- /dev/null
+++ b/client/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ אירועית - ניהול אירועים
+
+
+
+
+
+
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..4676c8b
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "airewit-client",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "react-router-dom": "^6.21.1",
+ "lucide-react": "^0.303.0",
+ "clsx": "^2.1.0",
+ "tailwind-merge": "^2.2.0"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.43",
+ "@types/react-dom": "^18.2.17",
+ "@vitejs/plugin-react": "^4.2.1",
+ "autoprefixer": "^10.4.16",
+ "postcss": "^8.4.32",
+ "tailwindcss": "^3.4.0",
+ "vite": "^5.0.8"
+ }
+}
diff --git a/client/postcss.config.js b/client/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/client/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/client/src/App.jsx b/client/src/App.jsx
new file mode 100644
index 0000000..fcae13c
--- /dev/null
+++ b/client/src/App.jsx
@@ -0,0 +1,39 @@
+import React, { useState, useEffect } from 'react';
+import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
+import { isLoggedIn } from './lib/auth';
+import LoginPage from './pages/LoginPage';
+import RegisterPage from './pages/RegisterPage';
+import EventsPage from './pages/EventsPage';
+import EventDetailPage from './pages/EventDetailPage';
+import Layout from './components/Layout';
+
+function ProtectedRoute({ children }) {
+ if (!isLoggedIn()) return ;
+ return children;
+}
+
+export default function App() {
+ return (
+
+
+ } />
+ } />
+
+
+
+
+
+ } />
+
+
+
+
+
+ } />
+ } />
+
+
+ );
+}
diff --git a/client/src/components/BudgetTab.jsx b/client/src/components/BudgetTab.jsx
new file mode 100644
index 0000000..42aa328
--- /dev/null
+++ b/client/src/components/BudgetTab.jsx
@@ -0,0 +1,226 @@
+import React, { useState, useEffect } from 'react';
+import { api } from '../lib/api';
+import { Plus, Trash2, Edit, Check, X, TrendingUp, TrendingDown, Wallet } from 'lucide-react';
+
+export default function BudgetTab({ eventId }) {
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showForm, setShowForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState({ title: '', type: 'expense', amount: '', description: '' });
+ const [error, setError] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ loadBudget();
+ }, [eventId]);
+
+ async function loadBudget() {
+ try {
+ const data = await api.getBudget(eventId);
+ setItems(data);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ const totalIncome = items.filter(i => i.type === 'income').reduce((s, i) => s + Number(i.amount), 0);
+ const totalExpenses = items.filter(i => i.type === 'expense').reduce((s, i) => s + Number(i.amount), 0);
+ const balance = totalIncome - totalExpenses;
+
+ function startAdd() {
+ setEditingId(null);
+ setForm({ title: '', type: 'expense', amount: '', description: '' });
+ setShowForm(true);
+ }
+
+ function startEdit(item) {
+ setEditingId(item.id);
+ setForm({ title: item.title, type: item.type, amount: item.amount, description: item.description || '' });
+ setShowForm(false);
+ }
+
+ function cancelForm() {
+ setShowForm(false);
+ setEditingId(null);
+ setForm({ title: '', type: 'expense', amount: '', description: '' });
+ setError('');
+ }
+
+ async function handleSave() {
+ if (!form.title.trim() || !form.amount) {
+ setError('שם וסכום נדרשים');
+ return;
+ }
+ if (Number(form.amount) <= 0) {
+ setError('הסכום חייב להיות חיובי');
+ return;
+ }
+ setSaving(true);
+ setError('');
+ try {
+ const payload = { ...form, amount: Number(form.amount) };
+ if (editingId) {
+ const updated = await api.updateBudgetItem(eventId, editingId, payload);
+ setItems(items.map(i => i.id === editingId ? updated : i));
+ } else {
+ const created = await api.createBudgetItem(eventId, payload);
+ setItems([...items, created]);
+ }
+ cancelForm();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete(id) {
+ if (!confirm('למחוק את הפריט?')) return;
+ try {
+ await api.deleteBudgetItem(eventId, id);
+ setItems(items.filter(i => i.id !== id));
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ const FormFields = () => (
+
+ setForm({ ...form, title: e.target.value })}
+ placeholder="שם הפריט *"
+ className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500" />
+
+ setForm({ ...form, amount: e.target.value })}
+ placeholder="סכום (₪) *" min="0.01" step="0.01"
+ className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500" />
+ setForm({ ...form, description: e.target.value })}
+ placeholder="תיאור"
+ className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500" />
+
+ );
+
+ if (loading) return טוען...
;
+
+ return (
+
+ {/* Summary */}
+
+
+
+
₪{totalIncome.toLocaleString()}
+
הכנסות
+
+
+
+
₪{totalExpenses.toLocaleString()}
+
הוצאות
+
+
= 0 ? 'bg-sky-50 border-sky-200' : 'bg-orange-50 border-orange-200'}`}>
+
= 0 ? 'text-sky-600' : 'text-orange-500'}`} />
+ = 0 ? 'text-sky-700' : 'text-orange-600'}`}>
+ ₪{Math.abs(balance).toLocaleString()}
+
+ = 0 ? 'text-sky-600' : 'text-orange-500'}`}>
+ {balance >= 0 ? 'יתרה' : 'גירעון'}
+
+
+
+
+
+
פריטי תקציב
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {showForm && (
+
+
פריט חדש
+
+
+
+
+
+
+ )}
+
+ {items.length === 0 && !showForm ? (
+
+
+
אין פריטי תקציב עדיין
+
+ ) : (
+
+ {items.map(item => (
+
+ {editingId === item.id ? (
+
+
+ {error &&
{error}
}
+
+
+
+
+
+ ) : (
+
+
+
+ {item.title}
+
+ {item.type === 'income' ? 'הכנסה' : 'הוצאה'}
+
+
+ {item.description &&
{item.description}
}
+
+
+
+ {item.type === 'income' ? '+' : '-'}₪{Number(item.amount).toLocaleString()}
+
+
+
+
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/client/src/components/EventFormModal.jsx b/client/src/components/EventFormModal.jsx
new file mode 100644
index 0000000..35a9acf
--- /dev/null
+++ b/client/src/components/EventFormModal.jsx
@@ -0,0 +1,145 @@
+import React, { useState } from 'react';
+import { X } from 'lucide-react';
+
+export default function EventFormModal({ event, onClose, onSave }) {
+ const [form, setForm] = useState({
+ title: event?.title || '',
+ description: event?.description || '',
+ event_date: event?.event_date ? event.event_date.slice(0, 16) : '',
+ location: event?.location || '',
+ max_participants: event?.max_participants || '',
+ budget: event?.budget || '',
+ });
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError('');
+ if (!form.title.trim()) { setError('שם האירוע נדרש'); return; }
+ if (!form.event_date) { setError('תאריך האירוע נדרש'); return; }
+
+ setLoading(true);
+ try {
+ await onSave({
+ ...form,
+ max_participants: form.max_participants ? Number(form.max_participants) : null,
+ budget: form.budget ? Number(form.budget) : 0,
+ });
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+ {event ? 'עריכת אירוע' : 'אירוע חדש'}
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx
new file mode 100644
index 0000000..0b295c3
--- /dev/null
+++ b/client/src/components/Layout.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { removeToken } from '../lib/auth';
+import { Calendar, LogOut } from 'lucide-react';
+
+export default function Layout({ children }) {
+ const navigate = useNavigate();
+
+ function handleLogout() {
+ removeToken();
+ navigate('/login');
+ }
+
+ return (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/client/src/components/ParticipantsTab.jsx b/client/src/components/ParticipantsTab.jsx
new file mode 100644
index 0000000..8690a34
--- /dev/null
+++ b/client/src/components/ParticipantsTab.jsx
@@ -0,0 +1,238 @@
+import React, { useState, useEffect } from 'react';
+import { api } from '../lib/api';
+import { Plus, Trash2, Edit, Check, X, User } from 'lucide-react';
+
+const STATUS_LABELS = {
+ invited: 'הוזמן',
+ confirmed: 'אישר הגעה',
+ declined: 'ביטל',
+ attended: 'השתתף',
+};
+
+const STATUS_COLORS = {
+ invited: 'bg-yellow-100 text-yellow-700',
+ confirmed: 'bg-green-100 text-green-700',
+ declined: 'bg-red-100 text-red-700',
+ attended: 'bg-blue-100 text-blue-700',
+};
+
+export default function ParticipantsTab({ eventId }) {
+ const [participants, setParticipants] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showForm, setShowForm] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [form, setForm] = useState({ name: '', email: '', phone: '', status: 'invited' });
+ const [error, setError] = useState('');
+ const [saving, setSaving] = useState(false);
+
+ useEffect(() => {
+ loadParticipants();
+ }, [eventId]);
+
+ async function loadParticipants() {
+ try {
+ const data = await api.getParticipants(eventId);
+ setParticipants(data);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function startEdit(p) {
+ setEditingId(p.id);
+ setForm({ name: p.name, email: p.email, phone: p.phone || '', status: p.status });
+ setShowForm(false);
+ }
+
+ function startAdd() {
+ setEditingId(null);
+ setForm({ name: '', email: '', phone: '', status: 'invited' });
+ setShowForm(true);
+ }
+
+ function cancelForm() {
+ setShowForm(false);
+ setEditingId(null);
+ setForm({ name: '', email: '', phone: '', status: 'invited' });
+ setError('');
+ }
+
+ async function handleSave() {
+ if (!form.name.trim() || !form.email.trim()) {
+ setError('שם ואימייל נדרשים');
+ return;
+ }
+ setSaving(true);
+ setError('');
+ try {
+ if (editingId) {
+ const updated = await api.updateParticipant(eventId, editingId, form);
+ setParticipants(participants.map(p => p.id === editingId ? updated : p));
+ } else {
+ const created = await api.createParticipant(eventId, form);
+ setParticipants([...participants, created]);
+ }
+ cancelForm();
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function handleDelete(id) {
+ if (!confirm('למחוק את המשתתף?')) return;
+ try {
+ await api.deleteParticipant(eventId, id);
+ setParticipants(participants.filter(p => p.id !== id));
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ const confirmed = participants.filter(p => p.status === 'confirmed').length;
+ const total = participants.length;
+
+ if (loading) return טוען...
;
+
+ return (
+
+
+
+
משתתפים
+
{total} סה"כ · {confirmed} אישרו הגעה
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {showForm && (
+
+ )}
+
+ {participants.length === 0 && !showForm ? (
+
+ ) : (
+
+ {participants.map(p => (
+
+ {editingId === p.id ? (
+
+ ) : (
+
+
+
+ {p.name}
+
+ {STATUS_LABELS[p.status]}
+
+
+
+ {p.email}{p.phone && ` · ${p.phone}`}
+
+
+
+
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/client/src/index.css b/client/src/index.css
new file mode 100644
index 0000000..2afe862
--- /dev/null
+++ b/client/src/index.css
@@ -0,0 +1,13 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
+ direction: rtl;
+}
diff --git a/client/src/lib/api.js b/client/src/lib/api.js
new file mode 100644
index 0000000..9d279a9
--- /dev/null
+++ b/client/src/lib/api.js
@@ -0,0 +1,52 @@
+const BASE_URL = '/api';
+
+function getToken() {
+ return localStorage.getItem('token');
+}
+
+async function request(path, options = {}) {
+ const token = getToken();
+ const headers = {
+ 'Content-Type': 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...options.headers,
+ };
+
+ const res = await fetch(`${BASE_URL}${path}`, {
+ ...options,
+ headers,
+ });
+
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ error: 'Request failed' }));
+ throw new Error(err.error || 'Request failed');
+ }
+
+ return res.json();
+}
+
+export const api = {
+ // Auth
+ register: (data) => request('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
+ login: (data) => request('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
+ me: () => request('/auth/me'),
+
+ // Events
+ getEvents: () => request('/events'),
+ getEvent: (id) => request(`/events/${id}`),
+ createEvent: (data) => request('/events', { method: 'POST', body: JSON.stringify(data) }),
+ updateEvent: (id, data) => request(`/events/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
+ deleteEvent: (id) => request(`/events/${id}`, { method: 'DELETE' }),
+
+ // Participants
+ getParticipants: (eventId) => request(`/events/${eventId}/participants`),
+ createParticipant: (eventId, data) => request(`/events/${eventId}/participants`, { method: 'POST', body: JSON.stringify(data) }),
+ updateParticipant: (eventId, id, data) => request(`/events/${eventId}/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
+ deleteParticipant: (eventId, id) => request(`/events/${eventId}/participants/${id}`, { method: 'DELETE' }),
+
+ // Budget
+ getBudget: (eventId) => request(`/events/${eventId}/budget`),
+ createBudgetItem: (eventId, data) => request(`/events/${eventId}/budget`, { method: 'POST', body: JSON.stringify(data) }),
+ updateBudgetItem: (eventId, id, data) => request(`/events/${eventId}/budget/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
+ deleteBudgetItem: (eventId, id) => request(`/events/${eventId}/budget/${id}`, { method: 'DELETE' }),
+};
diff --git a/client/src/lib/auth.js b/client/src/lib/auth.js
new file mode 100644
index 0000000..1665bfe
--- /dev/null
+++ b/client/src/lib/auth.js
@@ -0,0 +1,15 @@
+export function setToken(token) {
+ localStorage.setItem('token', token);
+}
+
+export function getToken() {
+ return localStorage.getItem('token');
+}
+
+export function removeToken() {
+ localStorage.removeItem('token');
+}
+
+export function isLoggedIn() {
+ return !!getToken();
+}
diff --git a/client/src/main.jsx b/client/src/main.jsx
new file mode 100644
index 0000000..54b39dd
--- /dev/null
+++ b/client/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.jsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+
+ ,
+)
diff --git a/client/src/pages/EventDetailPage.jsx b/client/src/pages/EventDetailPage.jsx
new file mode 100644
index 0000000..46e2df2
--- /dev/null
+++ b/client/src/pages/EventDetailPage.jsx
@@ -0,0 +1,121 @@
+import React, { useState, useEffect } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { api } from '../lib/api';
+import { ArrowRight, Calendar, MapPin, Users, Wallet } from 'lucide-react';
+import ParticipantsTab from '../components/ParticipantsTab';
+import BudgetTab from '../components/BudgetTab';
+
+export default function EventDetailPage() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const [event, setEvent] = useState(null);
+ const [activeTab, setActiveTab] = useState('participants');
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+
+ useEffect(() => {
+ loadEvent();
+ }, [id]);
+
+ async function loadEvent() {
+ try {
+ const data = await api.getEvent(id);
+ setEvent(data);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ function formatDate(dateStr) {
+ return new Date(dateStr).toLocaleDateString('he-IL', {
+ day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
+ });
+ }
+
+ if (loading) return (
+
+ );
+
+ if (error) return (
+
+ {error}
+
+ );
+
+ if (!event) return null;
+
+ return (
+
+
+
+
+
{event.title}
+
+ {event.description && (
+
{event.description}
+ )}
+
+
+
+
+ {formatDate(event.event_date)}
+
+ {event.location && (
+
+
+ {event.location}
+
+ )}
+ {event.max_participants && (
+
+
+ מקסימום {event.max_participants} משתתפים
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {activeTab === 'participants' &&
}
+ {activeTab === 'budget' &&
}
+
+
+
+ );
+}
diff --git a/client/src/pages/EventsPage.jsx b/client/src/pages/EventsPage.jsx
new file mode 100644
index 0000000..dad50da
--- /dev/null
+++ b/client/src/pages/EventsPage.jsx
@@ -0,0 +1,182 @@
+import React, { useState, useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { api } from '../lib/api';
+import { Plus, Calendar, MapPin, Users, Trash2, Edit, ChevronLeft } from 'lucide-react';
+import EventFormModal from '../components/EventFormModal';
+
+export default function EventsPage() {
+ const navigate = useNavigate();
+ const [events, setEvents] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState('');
+ const [showModal, setShowModal] = useState(false);
+ const [editingEvent, setEditingEvent] = useState(null);
+
+ useEffect(() => {
+ loadEvents();
+ }, []);
+
+ async function loadEvents() {
+ try {
+ const data = await api.getEvents();
+ setEvents(data);
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleDelete(id, e) {
+ e.stopPropagation();
+ if (!confirm('האם למחוק את האירוע?')) return;
+ try {
+ await api.deleteEvent(id);
+ setEvents(events.filter(ev => ev.id !== id));
+ } catch (err) {
+ alert(err.message);
+ }
+ }
+
+ function handleEdit(event, e) {
+ e.stopPropagation();
+ setEditingEvent(event);
+ setShowModal(true);
+ }
+
+ function handleModalClose() {
+ setShowModal(false);
+ setEditingEvent(null);
+ }
+
+ async function handleModalSave(data) {
+ try {
+ if (editingEvent) {
+ const updated = await api.updateEvent(editingEvent.id, data);
+ setEvents(events.map(ev => ev.id === updated.id ? { ...ev, ...updated } : ev));
+ } else {
+ const created = await api.createEvent(data);
+ setEvents([...events, { ...created, participant_count: 0, total_income: 0, total_expenses: 0 }]);
+ }
+ handleModalClose();
+ } catch (err) {
+ throw err;
+ }
+ }
+
+ function formatDate(dateStr) {
+ return new Date(dateStr).toLocaleDateString('he-IL', {
+ day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
+ });
+ }
+
+ if (loading) return (
+
+ );
+
+ return (
+
+
+
+
האירועים שלי
+
{events.length} אירועים
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {events.length === 0 ? (
+
+
+
אין אירועים עדיין
+
צור את האירוע הראשון שלך
+
+
+ ) : (
+
+ {events.map(event => (
+
navigate(`/events/${event.id}`)}
+ className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md hover:border-sky-200 transition-all cursor-pointer"
+ >
+
+
{event.title}
+
+
+
+
+
+
+ {event.description && (
+
{event.description}
+ )}
+
+
+
+
+ {formatDate(event.event_date)}
+
+ {event.location && (
+
+
+ {event.location}
+
+ )}
+
+
+ {event.participant_count} משתתפים
+
+
+
+
+
+ ₪{Number(event.total_income).toLocaleString()}
+ |
+ ₪{Number(event.total_expenses).toLocaleString()}
+
+
+
+
+ ))}
+
+ )}
+
+ {showModal && (
+
+ )}
+
+ );
+}
diff --git a/client/src/pages/LoginPage.jsx b/client/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..8102206
--- /dev/null
+++ b/client/src/pages/LoginPage.jsx
@@ -0,0 +1,87 @@
+import React, { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { api } from '../lib/api';
+import { setToken } from '../lib/auth';
+import { Calendar } from 'lucide-react';
+
+export default function LoginPage() {
+ const navigate = useNavigate();
+ const [form, setForm] = useState({ email: '', password: '' });
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError('');
+ setLoading(true);
+ try {
+ const { token } = await api.login(form);
+ setToken(token);
+ navigate('/');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
אירועית
+
פלטפורמה לניהול אירועים
+
+
+
+
+
+ אין לך חשבון?{' '}
+
+ הרשמה
+
+
+
+
+ );
+}
diff --git a/client/src/pages/RegisterPage.jsx b/client/src/pages/RegisterPage.jsx
new file mode 100644
index 0000000..9ad1c85
--- /dev/null
+++ b/client/src/pages/RegisterPage.jsx
@@ -0,0 +1,102 @@
+import React, { useState } from 'react';
+import { useNavigate, Link } from 'react-router-dom';
+import { api } from '../lib/api';
+import { setToken } from '../lib/auth';
+import { Calendar } from 'lucide-react';
+
+export default function RegisterPage() {
+ const navigate = useNavigate();
+ const [form, setForm] = useState({ name: '', email: '', password: '' });
+ const [error, setError] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ async function handleSubmit(e) {
+ e.preventDefault();
+ setError('');
+ if (form.password.length < 6) {
+ setError('הסיסמה חייבת להכיל לפחות 6 תווים');
+ return;
+ }
+ setLoading(true);
+ try {
+ const { token } = await api.register(form);
+ setToken(token);
+ navigate('/');
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
אירועית
+
צור חשבון חדש
+
+
+
+
+
+ כבר יש לך חשבון?{' '}
+
+ כניסה
+
+
+
+
+ );
+}
diff --git a/client/tailwind.config.js b/client/tailwind.config.js
new file mode 100644
index 0000000..7fda8c1
--- /dev/null
+++ b/client/tailwind.config.js
@@ -0,0 +1,21 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ primary: {
+ 50: '#f0f9ff',
+ 100: '#e0f2fe',
+ 500: '#0ea5e9',
+ 600: '#0284c7',
+ 700: '#0369a1',
+ }
+ }
+ },
+ },
+ plugins: [],
+}
diff --git a/client/vite.config.js b/client/vite.config.js
new file mode 100644
index 0000000..4f387b4
--- /dev/null
+++ b/client/vite.config.js
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ proxy: {
+ '/api': 'http://localhost:3000',
+ },
+ },
+})
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..505eab8
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,36 @@
+version: '3.8'
+
+services:
+ app:
+ build: .
+ expose:
+ - "3000"
+ environment:
+ - DATABASE_URL=postgresql://postgres:postgres@postgres:5432/airewit
+ - JWT_SECRET=${JWT_SECRET:-airewit-jwt-secret-change-in-production}
+ - NODE_ENV=production
+ - PORT=3000
+ depends_on:
+ postgres:
+ condition: service_healthy
+ restart: unless-stopped
+
+ postgres:
+ image: postgres:15-alpine
+ environment:
+ - POSTGRES_USER=postgres
+ - POSTGRES_PASSWORD=postgres
+ - POSTGRES_DB=airewit
+ expose:
+ - "5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U postgres"]
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ restart: unless-stopped
+
+volumes:
+ postgres_data:
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..668623c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "airewit",
+ "version": "1.0.0",
+ "description": "Event management platform",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node server.js"
+ },
+ "dependencies": {
+ "bcryptjs": "^2.4.3",
+ "cors": "^2.8.5",
+ "express": "^4.18.2",
+ "jsonwebtoken": "^9.0.2",
+ "pg": "^8.11.3",
+ "uuid": "^9.0.0"
+ }
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..f83762a
--- /dev/null
+++ b/server.js
@@ -0,0 +1,378 @@
+const express = require('express');
+const cors = require('cors');
+const { Pool } = require('pg');
+const bcrypt = require('bcryptjs');
+const jwt = require('jsonwebtoken');
+const { v4: uuidv4 } = require('uuid');
+const path = require('path');
+
+const app = express();
+const PORT = process.env.PORT || 3000;
+const JWT_SECRET = process.env.JWT_SECRET || 'airewit-secret-key-change-in-prod';
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@postgres:5432/airewit',
+});
+
+app.use(cors());
+app.use(express.json());
+app.use(express.static(path.join(__dirname, 'client/dist')));
+
+// Initialize DB tables
+async function initDB() {
+ const client = await pool.connect();
+ try {
+ await client.query(`
+ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
+
+ CREATE TABLE IF NOT EXISTS users (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ email VARCHAR(255) UNIQUE NOT NULL,
+ name VARCHAR(100) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP DEFAULT NOW()
+ );
+
+ CREATE TABLE IF NOT EXISTS events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID REFERENCES users(id) ON DELETE CASCADE,
+ title VARCHAR(255) NOT NULL,
+ description TEXT,
+ event_date TIMESTAMP NOT NULL,
+ location VARCHAR(255),
+ max_participants INTEGER,
+ budget DECIMAL(10,2) DEFAULT 0,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+ );
+
+ CREATE TABLE IF NOT EXISTS participants (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID REFERENCES events(id) ON DELETE CASCADE,
+ name VARCHAR(100) NOT NULL,
+ email VARCHAR(255) NOT NULL,
+ phone VARCHAR(50),
+ status VARCHAR(50) DEFAULT 'invited',
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+ );
+
+ CREATE TABLE IF NOT EXISTS budget_items (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_id UUID REFERENCES events(id) ON DELETE CASCADE,
+ title VARCHAR(255) NOT NULL,
+ type VARCHAR(20) NOT NULL CHECK (type IN ('income', 'expense')),
+ amount DECIMAL(10,2) NOT NULL,
+ description TEXT,
+ created_at TIMESTAMP DEFAULT NOW(),
+ updated_at TIMESTAMP DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
+ CREATE INDEX IF NOT EXISTS idx_participants_event_id ON participants(event_id);
+ CREATE INDEX IF NOT EXISTS idx_budget_items_event_id ON budget_items(event_id);
+ `);
+ console.log('Database initialized');
+ } catch (err) {
+ console.error('DB init error:', err.message);
+ } finally {
+ client.release();
+ }
+}
+
+// Auth middleware
+function authMiddleware(req, res, next) {
+ const token = req.headers.authorization?.replace('Bearer ', '');
+ if (!token) return res.status(401).json({ error: 'Unauthorized' });
+ try {
+ req.user = jwt.verify(token, JWT_SECRET);
+ next();
+ } catch {
+ res.status(401).json({ error: 'Invalid token' });
+ }
+}
+
+// Health check
+app.get('/health', async (req, res) => {
+ let dbOk = false;
+ try {
+ await pool.query('SELECT 1');
+ dbOk = true;
+ } catch {}
+ res.json({
+ status: 'ok',
+ commit: process.env.GIT_COMMIT || 'unknown',
+ db: dbOk ? 'connected' : 'error',
+ timestamp: new Date().toISOString()
+ });
+});
+
+// ========== AUTH ==========
+
+app.post('/api/auth/register', async (req, res) => {
+ const { email, name, password } = req.body;
+ if (!email || !name || !password) return res.status(400).json({ error: 'Missing fields' });
+ try {
+ const hash = await bcrypt.hash(password, 10);
+ const result = await pool.query(
+ 'INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, email, name, created_at',
+ [email, name, hash]
+ );
+ const user = result.rows[0];
+ const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
+ res.status(201).json({ user, token });
+ } catch (err) {
+ if (err.code === '23505') return res.status(409).json({ error: 'Email already exists' });
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.post('/api/auth/login', async (req, res) => {
+ const { email, password } = req.body;
+ if (!email || !password) return res.status(400).json({ error: 'Missing fields' });
+ try {
+ const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
+ const user = result.rows[0];
+ if (!user) return res.status(401).json({ error: 'Invalid credentials' });
+ const valid = await bcrypt.compare(password, user.password_hash);
+ if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
+ const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
+ res.json({ user: { id: user.id, email: user.email, name: user.name }, token });
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.get('/api/auth/me', authMiddleware, async (req, res) => {
+ try {
+ const result = await pool.query('SELECT id, email, name, created_at FROM users WHERE id = $1', [req.user.userId]);
+ if (!result.rows[0]) return res.status(404).json({ error: 'User not found' });
+ res.json(result.rows[0]);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+// ========== EVENTS ==========
+
+app.get('/api/events', authMiddleware, async (req, res) => {
+ try {
+ const result = await pool.query(
+ `SELECT e.*,
+ COUNT(DISTINCT p.id) AS participant_count,
+ COALESCE(SUM(CASE WHEN bi.type='income' THEN bi.amount ELSE 0 END), 0) AS total_income,
+ COALESCE(SUM(CASE WHEN bi.type='expense' THEN bi.amount ELSE 0 END), 0) AS total_expenses
+ FROM events e
+ LEFT JOIN participants p ON p.event_id = e.id
+ LEFT JOIN budget_items bi ON bi.event_id = e.id
+ WHERE e.user_id = $1
+ GROUP BY e.id
+ ORDER BY e.event_date ASC`,
+ [req.user.userId]
+ );
+ res.json(result.rows);
+ } catch (err) {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.get('/api/events/:id', authMiddleware, async (req, res) => {
+ try {
+ const result = await pool.query(
+ 'SELECT * FROM events WHERE id = $1 AND user_id = $2',
+ [req.params.id, req.user.userId]
+ );
+ if (!result.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ res.json(result.rows[0]);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.post('/api/events', authMiddleware, async (req, res) => {
+ const { title, description, event_date, location, max_participants, budget } = req.body;
+ if (!title || !event_date) return res.status(400).json({ error: 'Title and event_date are required' });
+ try {
+ const result = await pool.query(
+ `INSERT INTO events (user_id, title, description, event_date, location, max_participants, budget)
+ VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
+ [req.user.userId, title, description, event_date, location, max_participants || null, budget || 0]
+ );
+ res.status(201).json(result.rows[0]);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.put('/api/events/:id', authMiddleware, async (req, res) => {
+ const { title, description, event_date, location, max_participants, budget } = req.body;
+ if (!title || !event_date) return res.status(400).json({ error: 'Title and event_date are required' });
+ try {
+ const result = await pool.query(
+ `UPDATE events SET title=$1, description=$2, event_date=$3, location=$4,
+ max_participants=$5, budget=$6, updated_at=NOW()
+ WHERE id=$7 AND user_id=$8 RETURNING *`,
+ [title, description, event_date, location, max_participants || null, budget || 0, req.params.id, req.user.userId]
+ );
+ if (!result.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ res.json(result.rows[0]);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.delete('/api/events/:id', authMiddleware, async (req, res) => {
+ try {
+ const result = await pool.query(
+ 'DELETE FROM events WHERE id=$1 AND user_id=$2 RETURNING id',
+ [req.params.id, req.user.userId]
+ );
+ if (!result.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ res.json({ success: true });
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+// ========== PARTICIPANTS ==========
+
+app.get('/api/events/:eventId/participants', authMiddleware, async (req, res) => {
+ try {
+ // Verify event belongs to user
+ const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
+ if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ const result = await pool.query(
+ 'SELECT * FROM participants WHERE event_id=$1 ORDER BY created_at ASC',
+ [req.params.eventId]
+ );
+ res.json(result.rows);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.post('/api/events/:eventId/participants', authMiddleware, async (req, res) => {
+ const { name, email, phone, status } = req.body;
+ if (!name || !email) return res.status(400).json({ error: 'Name and email are required' });
+ try {
+ const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
+ if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ const result = await pool.query(
+ 'INSERT INTO participants (event_id, name, email, phone, status) VALUES ($1,$2,$3,$4,$5) RETURNING *',
+ [req.params.eventId, name, email, phone || null, status || 'invited']
+ );
+ res.status(201).json(result.rows[0]);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.put('/api/events/:eventId/participants/:id', authMiddleware, async (req, res) => {
+ const { name, email, phone, status } = req.body;
+ if (!name || !email) return res.status(400).json({ error: 'Name and email are required' });
+ try {
+ const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
+ if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ const result = await pool.query(
+ `UPDATE participants SET name=$1, email=$2, phone=$3, status=$4, updated_at=NOW()
+ WHERE id=$5 AND event_id=$6 RETURNING *`,
+ [name, email, phone || null, status || 'invited', req.params.id, req.params.eventId]
+ );
+ if (!result.rows[0]) return res.status(404).json({ error: 'Participant not found' });
+ res.json(result.rows[0]);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.delete('/api/events/:eventId/participants/:id', authMiddleware, async (req, res) => {
+ try {
+ const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
+ if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ const result = await pool.query(
+ 'DELETE FROM participants WHERE id=$1 AND event_id=$2 RETURNING id',
+ [req.params.id, req.params.eventId]
+ );
+ if (!result.rows[0]) return res.status(404).json({ error: 'Participant not found' });
+ res.json({ success: true });
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+// ========== BUDGET ITEMS ==========
+
+app.get('/api/events/:eventId/budget', authMiddleware, async (req, res) => {
+ try {
+ const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
+ if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ const result = await pool.query(
+ 'SELECT * FROM budget_items WHERE event_id=$1 ORDER BY created_at ASC',
+ [req.params.eventId]
+ );
+ res.json(result.rows);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.post('/api/events/:eventId/budget', authMiddleware, async (req, res) => {
+ const { title, type, amount, description } = req.body;
+ if (!title || !type || !amount) return res.status(400).json({ error: 'Title, type, and amount are required' });
+ if (!['income', 'expense'].includes(type)) return res.status(400).json({ error: 'Type must be income or expense' });
+ try {
+ const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
+ if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ const result = await pool.query(
+ 'INSERT INTO budget_items (event_id, title, type, amount, description) VALUES ($1,$2,$3,$4,$5) RETURNING *',
+ [req.params.eventId, title, type, amount, description || null]
+ );
+ res.status(201).json(result.rows[0]);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.put('/api/events/:eventId/budget/:id', authMiddleware, async (req, res) => {
+ const { title, type, amount, description } = req.body;
+ if (!title || !type || !amount) return res.status(400).json({ error: 'Title, type, and amount are required' });
+ if (!['income', 'expense'].includes(type)) return res.status(400).json({ error: 'Type must be income or expense' });
+ try {
+ const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
+ if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ const result = await pool.query(
+ `UPDATE budget_items SET title=$1, type=$2, amount=$3, description=$4, updated_at=NOW()
+ WHERE id=$5 AND event_id=$6 RETURNING *`,
+ [title, type, amount, description || null, req.params.id, req.params.eventId]
+ );
+ if (!result.rows[0]) return res.status(404).json({ error: 'Budget item not found' });
+ res.json(result.rows[0]);
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+app.delete('/api/events/:eventId/budget/:id', authMiddleware, async (req, res) => {
+ try {
+ const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
+ if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
+ const result = await pool.query(
+ 'DELETE FROM budget_items WHERE id=$1 AND event_id=$2 RETURNING id',
+ [req.params.id, req.params.eventId]
+ );
+ if (!result.rows[0]) return res.status(404).json({ error: 'Budget item not found' });
+ res.json({ success: true });
+ } catch {
+ res.status(500).json({ error: 'Server error' });
+ }
+});
+
+// SPA fallback
+app.get('*', (req, res) => {
+ res.sendFile(path.join(__dirname, 'client/dist/index.html'));
+});
+
+app.listen(PORT, async () => {
+ console.log(`Server running on port ${PORT}`);
+ await initDB();
+});