Compare commits
5 Commits
107c208746
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1659c07ea | ||
|
|
6f1d09cd77 | ||
|
|
b8f2d12011 | ||
|
|
dac8b43915 | ||
|
|
9edfe6aa62 |
@@ -104,7 +104,7 @@ docker-compose logs -f
|
|||||||
|
|
||||||
**Admin**: admin@swanseaairport.org / admin123
|
**Admin**: admin@swanseaairport.org / admin123
|
||||||
|
|
||||||
**Database**: membership_user / SecureMembershipPass2024!
|
**Database**: Configured via environment variables (see .env file)
|
||||||
|
|
||||||
## What's Next
|
## What's Next
|
||||||
|
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ docker-compose up -d --build
|
|||||||
# Check status
|
# Check status
|
||||||
docker-compose ps
|
docker-compose ps
|
||||||
|
|
||||||
# Access MySQL CLI
|
# Access MySQL CLI (using environment variables)
|
||||||
docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db
|
docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}"
|
||||||
|
|
||||||
# Create database backup
|
# Create database backup
|
||||||
docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql
|
docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
```
|
```
|
||||||
|
|
||||||
## Default Admin Access
|
## Default Admin Access
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -25,6 +25,11 @@ A comprehensive membership management system built with FastAPI, MySQL, and Dock
|
|||||||
```
|
```
|
||||||
membership/
|
membership/
|
||||||
├── backend/
|
├── backend/
|
||||||
|
│ ├── alembic/ # Database migration scripts
|
||||||
|
│ │ ├── versions/ # Migration files
|
||||||
|
│ │ ├── env.py # Migration environment
|
||||||
|
│ │ └── script.py.mako # Migration template
|
||||||
|
│ ├── alembic.ini # Alembic configuration
|
||||||
│ ├── app/
|
│ ├── app/
|
||||||
│ │ ├── api/
|
│ │ ├── api/
|
||||||
│ │ │ ├── v1/
|
│ │ │ ├── v1/
|
||||||
@@ -46,7 +51,7 @@ membership/
|
|||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ └── requirements.txt
|
│ └── requirements.txt
|
||||||
├── database/
|
├── database/
|
||||||
│ └── init.sql # Database initialization
|
│ └── init.sql # Legacy database initialization (deprecated - use Alembic migrations)
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── .env.example
|
├── .env.example
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -205,16 +210,36 @@ docker-compose ps
|
|||||||
### Database Operations
|
### Database Operations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Access MySQL CLI
|
# Access MySQL CLI (using environment variables)
|
||||||
docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db
|
docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}"
|
||||||
|
|
||||||
# Create backup
|
# Create backup
|
||||||
docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql
|
docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||||
|
|
||||||
# Restore database
|
# Restore database
|
||||||
docker exec -i membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db < backup.sql
|
docker exec -i membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" < backup.sql
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
The application uses Alembic for database schema migrations. Migrations are automatically run when the backend container starts.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a new migration (after making model changes)
|
||||||
|
sudo docker compose exec backend alembic revision --autogenerate -m "Description of changes"
|
||||||
|
|
||||||
|
# Apply migrations
|
||||||
|
sudo docker compose exec backend alembic upgrade head
|
||||||
|
|
||||||
|
# View migration status
|
||||||
|
sudo docker compose exec backend alembic current
|
||||||
|
|
||||||
|
# View migration history
|
||||||
|
sudo docker compose exec backend alembic history
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The `database/init.sql` file is deprecated. All schema changes should now be made through Alembic migrations.
|
||||||
|
|
||||||
## API Testing
|
## API Testing
|
||||||
|
|
||||||
You can use the interactive API documentation at http://localhost:8000/docs to test endpoints:
|
You can use the interactive API documentation at http://localhost:8000/docs to test endpoints:
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ docker-compose exec backend pip list | grep square
|
|||||||
docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
|
docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
|
||||||
|
|
||||||
# Check database has payments
|
# Check database has payments
|
||||||
docker-compose exec mysql mysql -u membership_user -p -e "SELECT * FROM membership_db.payments LIMIT 5;"
|
docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
|
||||||
|
|
||||||
# Check frontend files
|
# Check frontend files
|
||||||
ls -la frontend/src/components/SquarePayment.tsx
|
ls -la frontend/src/components/SquarePayment.tsx
|
||||||
|
|||||||
118
backend/alembic.ini
Normal file
118
backend/alembic.ini
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the python>=3.9 or backports.zoneinfo library.
|
||||||
|
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||||
|
|
||||||
|
# version path separator; As mentioned above, this is the character used to split
|
||||||
|
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||||
|
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||||
|
# Valid values for version_path_separator are:
|
||||||
|
#
|
||||||
|
# version_path_separator = :
|
||||||
|
# version_path_separator = ;
|
||||||
|
# version_path_separator = space
|
||||||
|
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# Database URL - will be overridden by environment variables in production
|
||||||
|
# sqlalchemy.url = mysql+pymysql://username:password@host:port/database
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||||
|
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
97
backend/alembic/env.py
Normal file
97
backend/alembic/env.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
import os
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
from app.models.models import Base
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# Set database URL from environment variables if available
|
||||||
|
def get_database_url():
|
||||||
|
"""Get database URL from environment variables or config"""
|
||||||
|
# Try to get from environment variables first
|
||||||
|
db_host = os.getenv("DATABASE_HOST")
|
||||||
|
db_port = os.getenv("DATABASE_PORT", "3306")
|
||||||
|
db_user = os.getenv("DATABASE_USER")
|
||||||
|
db_password = os.getenv("DATABASE_PASSWORD")
|
||||||
|
db_name = os.getenv("DATABASE_NAME")
|
||||||
|
|
||||||
|
if all([db_host, db_user, db_password, db_name]):
|
||||||
|
return f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
|
||||||
|
|
||||||
|
# Fallback to config file
|
||||||
|
return config.get_main_option("sqlalchemy.url")
|
||||||
|
|
||||||
|
# Set the database URL
|
||||||
|
config.set_main_option("sqlalchemy.url", get_database_url())
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = get_database_url()
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""Create initial database schema from scratch
|
||||||
|
|
||||||
|
Revision ID: b583fd2cf202
|
||||||
|
Revises: f9fbfa70654e
|
||||||
|
Create Date: 2025-11-22 21:02:08.977255
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'b583fd2cf202'
|
||||||
|
down_revision: Union[str, None] = 'f9fbfa70654e'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create tables in dependency order (no foreign keys first, then with foreign keys)
|
||||||
|
|
||||||
|
# Users table (no dependencies)
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('hashed_password', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('first_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('last_name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('phone', sa.String(length=20), nullable=True),
|
||||||
|
sa.Column('address', sa.Text(), nullable=True),
|
||||||
|
sa.Column('role', sa.Enum('member', 'admin', 'super_admin', name='userrole'), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('last_login', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||||
|
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Membership tiers table (no dependencies)
|
||||||
|
op.create_table('membership_tiers',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('annual_fee', sa.Float(), nullable=False),
|
||||||
|
sa.Column('benefits', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_membership_tiers_id'), 'membership_tiers', ['id'], unique=False)
|
||||||
|
op.create_index('ix_membership_tiers_name', 'membership_tiers', ['name'], unique=True)
|
||||||
|
|
||||||
|
# Volunteer roles table (no dependencies)
|
||||||
|
op.create_table('volunteer_roles',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_volunteer_roles_id'), 'volunteer_roles', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Email templates table (no dependencies)
|
||||||
|
op.create_table('email_templates',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('template_key', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('subject', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('html_body', sa.Text(), nullable=False),
|
||||||
|
sa.Column('text_body', sa.Text(), nullable=True),
|
||||||
|
sa.Column('variables', sa.Text(), nullable=True),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_email_templates_id'), 'email_templates', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_email_templates_template_key'), 'email_templates', ['template_key'], unique=True)
|
||||||
|
|
||||||
|
# Email bounces table (no dependencies)
|
||||||
|
op.create_table('email_bounces',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('email', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('bounce_type', sa.Enum('hard', 'soft', 'complaint', 'unsubscribe', name='bouncetype'), nullable=False),
|
||||||
|
sa.Column('bounce_reason', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('smtp2go_message_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('bounce_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_email_bounces_email'), 'email_bounces', ['email'], unique=False)
|
||||||
|
op.create_index(op.f('ix_email_bounces_id'), 'email_bounces', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_email_bounces_smtp2go_message_id'), 'email_bounces', ['smtp2go_message_id'], unique=False)
|
||||||
|
|
||||||
|
# Memberships table (depends on users, membership_tiers)
|
||||||
|
op.create_table('memberships',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('tier_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('active', 'expired', 'pending', 'cancelled', name='membershipstatus'), nullable=False),
|
||||||
|
sa.Column('start_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('end_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('auto_renew', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['tier_id'], ['membership_tiers.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_memberships_id'), 'memberships', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Payments table (depends on users, memberships)
|
||||||
|
op.create_table('payments',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('membership_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('amount', sa.Float(), nullable=False),
|
||||||
|
sa.Column('payment_method', sa.Enum('square', 'cash', 'check', 'dummy', name='paymentmethod'), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('pending', 'completed', 'failed', 'refunded', name='paymentstatus'), nullable=False),
|
||||||
|
sa.Column('transaction_id', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('payment_date', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['membership_id'], ['memberships.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_payments_id'), 'payments', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Events table (depends on users)
|
||||||
|
op.create_table('events',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('event_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('event_time', sa.String(length=10), nullable=True),
|
||||||
|
sa.Column('location', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('max_attendees', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('status', sa.Enum('draft', 'published', 'cancelled', 'completed', name='eventstatus'), nullable=False),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_events_id'), 'events', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Event RSVPs table (depends on events, users)
|
||||||
|
op.create_table('event_rsvps',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('event_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('status', sa.Enum('pending', 'attending', 'not_attending', 'maybe', name='rsvpstatus'), nullable=False),
|
||||||
|
sa.Column('attended', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_event_rsvps_id'), 'event_rsvps', ['id'], unique=False)
|
||||||
|
op.create_index('ix_event_rsvps_event_id_user_id', 'event_rsvps', ['event_id', 'user_id'], unique=True)
|
||||||
|
|
||||||
|
# Volunteer assignments table (depends on users, volunteer_roles)
|
||||||
|
op.create_table('volunteer_assignments',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('role_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assigned_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['role_id'], ['volunteer_roles.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_volunteer_assignments_id'), 'volunteer_assignments', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Volunteer schedules table (depends on volunteer_assignments)
|
||||||
|
op.create_table('volunteer_schedules',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('assignment_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('schedule_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('start_time', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('end_time', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('location', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('completed', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['assignment_id'], ['volunteer_assignments.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_volunteer_schedules_id'), 'volunteer_schedules', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Certificates table (depends on users)
|
||||||
|
op.create_table('certificates',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('certificate_name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('issuing_organization', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('issue_date', sa.Date(), nullable=False),
|
||||||
|
sa.Column('expiry_date', sa.Date(), nullable=True),
|
||||||
|
sa.Column('certificate_number', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('file_path', sa.String(length=500), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_certificates_id'), 'certificates', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Files table (depends on users, membership_tiers)
|
||||||
|
op.create_table('files',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('original_filename', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('file_path', sa.String(length=500), nullable=False),
|
||||||
|
sa.Column('file_size', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('mime_type', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('min_tier_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('uploaded_by', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['min_tier_id'], ['membership_tiers.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Notifications table (depends on users)
|
||||||
|
op.create_table('notifications',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('subject', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('message', sa.Text(), nullable=False),
|
||||||
|
sa.Column('email_sent', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('sent_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_notifications_id'), 'notifications', ['id'], unique=False)
|
||||||
|
|
||||||
|
# Password reset tokens table (depends on users)
|
||||||
|
op.create_table('password_reset_tokens',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('token', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('used', sa.Boolean(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop tables in reverse dependency order
|
||||||
|
op.drop_table('password_reset_tokens')
|
||||||
|
op.drop_table('notifications')
|
||||||
|
op.drop_table('files')
|
||||||
|
op.drop_table('certificates')
|
||||||
|
op.drop_table('volunteer_schedules')
|
||||||
|
op.drop_table('volunteer_assignments')
|
||||||
|
op.drop_table('event_rsvps')
|
||||||
|
op.drop_table('events')
|
||||||
|
op.drop_table('payments')
|
||||||
|
op.drop_table('memberships')
|
||||||
|
op.drop_table('email_bounces')
|
||||||
|
op.drop_table('email_templates')
|
||||||
|
op.drop_table('volunteer_roles')
|
||||||
|
op.drop_table('membership_tiers')
|
||||||
|
op.drop_table('users')
|
||||||
|
|
||||||
|
# Drop enums
|
||||||
|
op.execute("DROP TYPE IF EXISTS userrole")
|
||||||
|
op.execute("DROP TYPE IF EXISTS membershipstatus")
|
||||||
|
op.execute("DROP TYPE IF EXISTS paymentmethod")
|
||||||
|
op.execute("DROP TYPE IF EXISTS paymentstatus")
|
||||||
|
op.execute("DROP TYPE IF EXISTS eventstatus")
|
||||||
|
op.execute("DROP TYPE IF EXISTS rsvpstatus")
|
||||||
|
op.execute("DROP TYPE IF EXISTS bouncetype")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Initial baseline migration
|
||||||
|
|
||||||
|
Revision ID: f9fbfa70654e
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-11-22 20:53:20.604227
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'f9fbfa70654e'
|
||||||
|
down_revision: Union[str, None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from . import auth, users, tiers, memberships, payments, email, email_templates, events
|
from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
@@ -11,3 +11,4 @@ api_router.include_router(payments.router, prefix="/payments", tags=["payments"]
|
|||||||
api_router.include_router(email.router, prefix="/email", tags=["email"])
|
api_router.include_router(email.router, prefix="/email", tags=["email"])
|
||||||
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
|
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
|
||||||
api_router.include_router(events.router, prefix="/events", tags=["events"])
|
api_router.include_router(events.router, prefix="/events", tags=["events"])
|
||||||
|
api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])
|
||||||
|
|||||||
47
backend/app/api/v1/feature_flags.py
Normal file
47
backend/app/api/v1/feature_flags.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from typing import Dict, Any
|
||||||
|
from app.services.feature_flag_service import feature_flags
|
||||||
|
from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flags", response_model=FeatureFlagsResponse)
|
||||||
|
async def get_all_feature_flags() -> FeatureFlagsResponse:
|
||||||
|
"""
|
||||||
|
Get all feature flags for the frontend
|
||||||
|
This endpoint is public as it only returns feature configuration
|
||||||
|
"""
|
||||||
|
all_flags = feature_flags.get_all_flags()
|
||||||
|
enabled_flags = feature_flags.get_enabled_flags()
|
||||||
|
|
||||||
|
return FeatureFlagsResponse(
|
||||||
|
flags=all_flags,
|
||||||
|
enabled_flags=enabled_flags
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/flags/{flag_name}", response_model=FeatureFlagResponse)
|
||||||
|
async def get_feature_flag(flag_name: str) -> FeatureFlagResponse:
|
||||||
|
"""
|
||||||
|
Get a specific feature flag value
|
||||||
|
"""
|
||||||
|
flag_name_upper = flag_name.upper()
|
||||||
|
enabled = feature_flags.is_enabled(flag_name_upper)
|
||||||
|
value = feature_flags.get_flag_value(flag_name_upper)
|
||||||
|
|
||||||
|
return FeatureFlagResponse(
|
||||||
|
name=flag_name_upper,
|
||||||
|
enabled=enabled,
|
||||||
|
value=value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/flags/reload")
|
||||||
|
async def reload_feature_flags():
|
||||||
|
"""
|
||||||
|
Reload feature flags from environment variables
|
||||||
|
This could be protected with admin permissions in production
|
||||||
|
"""
|
||||||
|
feature_flags.reload_flags()
|
||||||
|
return {"message": "Feature flags reloaded successfully"}
|
||||||
@@ -190,7 +190,8 @@ async def process_square_payment(
|
|||||||
source_id=payment_request.source_id,
|
source_id=payment_request.source_id,
|
||||||
idempotency_key=payment_request.idempotency_key,
|
idempotency_key=payment_request.idempotency_key,
|
||||||
reference_id=reference_id,
|
reference_id=reference_id,
|
||||||
note=payment_request.note or f"Membership payment for {tier.name} - {current_user.email}"
|
note=payment_request.note or f"Membership payment for {tier.name} - {current_user.email}",
|
||||||
|
billing_details=payment_request.billing_details
|
||||||
)
|
)
|
||||||
|
|
||||||
if not square_result.get('success'):
|
if not square_result.get('success'):
|
||||||
|
|||||||
@@ -1,12 +1,34 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from .core.config import settings
|
from .core.config import settings
|
||||||
from .api.v1 import api_router
|
from .api.v1 import api_router
|
||||||
|
from .core.database import get_db
|
||||||
|
from .core.init_db import init_default_data
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Handle startup and shutdown events"""
|
||||||
|
# Startup
|
||||||
|
db: Session = next(get_db())
|
||||||
|
try:
|
||||||
|
init_default_data(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
# Shutdown (if needed)
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.APP_NAME,
|
title=settings.APP_NAME,
|
||||||
version=settings.APP_VERSION,
|
version=settings.APP_VERSION,
|
||||||
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json"
|
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json",
|
||||||
|
lifespan=lifespan
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set up CORS
|
# Set up CORS
|
||||||
|
|||||||
15
backend/app/schemas/feature_flags.py
Normal file
15
backend/app/schemas/feature_flags.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagsResponse(BaseModel):
|
||||||
|
"""Response model for feature flags"""
|
||||||
|
flags: Dict[str, Any]
|
||||||
|
enabled_flags: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagResponse(BaseModel):
|
||||||
|
"""Response model for a single feature flag"""
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
value: Any
|
||||||
@@ -171,6 +171,7 @@ class SquarePaymentRequest(BaseModel):
|
|||||||
amount: float = Field(..., gt=0, description="Payment amount in GBP")
|
amount: float = Field(..., gt=0, description="Payment amount in GBP")
|
||||||
idempotency_key: Optional[str] = Field(None, description="Unique key to prevent duplicate payments")
|
idempotency_key: Optional[str] = Field(None, description="Unique key to prevent duplicate payments")
|
||||||
note: Optional[str] = Field(None, description="Optional payment note")
|
note: Optional[str] = Field(None, description="Optional payment note")
|
||||||
|
billing_details: Optional[dict] = Field(None, description="Billing address and cardholder name for AVS")
|
||||||
|
|
||||||
|
|
||||||
class SquarePaymentResponse(BaseModel):
|
class SquarePaymentResponse(BaseModel):
|
||||||
|
|||||||
80
backend/app/services/feature_flag_service.py
Normal file
80
backend/app/services/feature_flag_service.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Feature Flag Service for managing application features
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class FeatureFlagService:
|
||||||
|
"""Service for managing feature flags"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize feature flags from environment variables"""
|
||||||
|
self._flags = self._load_flags_from_env()
|
||||||
|
|
||||||
|
def _load_flags_from_env(self) -> Dict[str, Any]:
|
||||||
|
"""Load feature flags from environment variables"""
|
||||||
|
# Get the FEATURE_FLAGS environment variable (comma-separated list)
|
||||||
|
feature_flags_env = os.getenv("FEATURE_FLAGS", "")
|
||||||
|
|
||||||
|
# Default feature flags - these can be overridden by environment
|
||||||
|
default_flags = {
|
||||||
|
"CASH_PAYMENT_ENABLED": True,
|
||||||
|
"EMAIL_NOTIFICATIONS_ENABLED": True,
|
||||||
|
"EVENT_MANAGEMENT_ENABLED": True,
|
||||||
|
"AUTO_RENEWAL_ENABLED": False,
|
||||||
|
"MEMBERSHIP_TRANSFERS_ENABLED": False,
|
||||||
|
"BULK_OPERATIONS_ENABLED": False,
|
||||||
|
"ADVANCED_REPORTING_ENABLED": False,
|
||||||
|
"API_RATE_LIMITING_ENABLED": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse environment variable
|
||||||
|
flags = default_flags.copy()
|
||||||
|
|
||||||
|
if feature_flags_env:
|
||||||
|
# Parse comma-separated key=value pairs
|
||||||
|
for flag_pair in feature_flags_env.split(","):
|
||||||
|
flag_pair = flag_pair.strip()
|
||||||
|
if "=" in flag_pair:
|
||||||
|
key, value = flag_pair.split("=", 1)
|
||||||
|
key = key.strip().upper()
|
||||||
|
value = value.strip().lower()
|
||||||
|
|
||||||
|
# Convert string to boolean
|
||||||
|
if value in ("true", "1", "yes", "on"):
|
||||||
|
flags[key] = True
|
||||||
|
elif value in ("false", "0", "no", "off"):
|
||||||
|
flags[key] = False
|
||||||
|
else:
|
||||||
|
# For non-boolean values, keep as string
|
||||||
|
flags[key] = value
|
||||||
|
|
||||||
|
return flags
|
||||||
|
|
||||||
|
def is_enabled(self, flag_name: str) -> bool:
|
||||||
|
"""Check if a feature flag is enabled"""
|
||||||
|
flag_name = flag_name.upper()
|
||||||
|
return bool(self._flags.get(flag_name, False))
|
||||||
|
|
||||||
|
def get_flag_value(self, flag_name: str, default: Any = None) -> Any:
|
||||||
|
"""Get the value of a feature flag"""
|
||||||
|
flag_name = flag_name.upper()
|
||||||
|
return self._flags.get(flag_name, default)
|
||||||
|
|
||||||
|
def get_all_flags(self) -> Dict[str, Any]:
|
||||||
|
"""Get all feature flags"""
|
||||||
|
return self._flags.copy()
|
||||||
|
|
||||||
|
def get_enabled_flags(self) -> List[str]:
|
||||||
|
"""Get list of enabled feature flag names"""
|
||||||
|
return [name for name, value in self._flags.items() if value is True]
|
||||||
|
|
||||||
|
def reload_flags(self) -> None:
|
||||||
|
"""Reload feature flags from environment (useful for runtime updates)"""
|
||||||
|
self._flags = self._load_flags_from_env()
|
||||||
|
|
||||||
|
|
||||||
|
# Global instance
|
||||||
|
feature_flags = FeatureFlagService()
|
||||||
@@ -35,7 +35,8 @@ class SquareService:
|
|||||||
idempotency_key: Optional[str] = None,
|
idempotency_key: Optional[str] = None,
|
||||||
customer_id: Optional[str] = None,
|
customer_id: Optional[str] = None,
|
||||||
reference_id: Optional[str] = None,
|
reference_id: Optional[str] = None,
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None,
|
||||||
|
billing_details: Optional[Dict] = None
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""
|
"""
|
||||||
Create a payment using Square
|
Create a payment using Square
|
||||||
@@ -47,6 +48,7 @@ class SquareService:
|
|||||||
customer_id: Optional Square customer ID
|
customer_id: Optional Square customer ID
|
||||||
reference_id: Optional reference ID for internal tracking
|
reference_id: Optional reference ID for internal tracking
|
||||||
note: Optional note about the payment
|
note: Optional note about the payment
|
||||||
|
billing_details: Optional billing address and cardholder name for AVS
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with payment result including payment_id, status, and details
|
Dict with payment result including payment_id, status, and details
|
||||||
@@ -60,19 +62,49 @@ class SquareService:
|
|||||||
# For GBP, this is pence
|
# For GBP, this is pence
|
||||||
amount_in_pence = int(amount_money * 100)
|
amount_in_pence = int(amount_money * 100)
|
||||||
|
|
||||||
# Create payment - pass parameters directly as keyword arguments
|
# Build payment parameters
|
||||||
result = self.client.payments.create(
|
payment_params = {
|
||||||
source_id=source_id,
|
'source_id': source_id,
|
||||||
idempotency_key=idempotency_key,
|
'idempotency_key': idempotency_key,
|
||||||
amount_money={
|
'amount_money': {
|
||||||
'amount': amount_in_pence,
|
'amount': amount_in_pence,
|
||||||
'currency': 'GBP'
|
'currency': 'GBP'
|
||||||
},
|
},
|
||||||
location_id=self.location_id,
|
'location_id': self.location_id
|
||||||
customer_id=customer_id if customer_id else None,
|
}
|
||||||
reference_id=reference_id if reference_id else None,
|
|
||||||
note=note if note else None
|
# Add billing address for AVS if provided
|
||||||
)
|
if billing_details:
|
||||||
|
# Add buyer email if available
|
||||||
|
if billing_details.get('email'):
|
||||||
|
payment_params['buyer_email_address'] = billing_details.get('email')
|
||||||
|
|
||||||
|
# Build billing address for AVS
|
||||||
|
billing_address = {}
|
||||||
|
if billing_details.get('address_line_1'):
|
||||||
|
billing_address['address_line_1'] = billing_details.get('address_line_1')
|
||||||
|
if billing_details.get('address_line_2'):
|
||||||
|
billing_address['address_line_2'] = billing_details.get('address_line_2')
|
||||||
|
if billing_details.get('city'):
|
||||||
|
billing_address['locality'] = billing_details.get('city')
|
||||||
|
if billing_details.get('postal_code'):
|
||||||
|
billing_address['postal_code'] = billing_details.get('postal_code')
|
||||||
|
if billing_details.get('country'):
|
||||||
|
billing_address['country'] = billing_details.get('country')
|
||||||
|
|
||||||
|
if billing_address:
|
||||||
|
payment_params['billing_address'] = billing_address
|
||||||
|
|
||||||
|
# Add optional parameters
|
||||||
|
if customer_id:
|
||||||
|
payment_params['customer_id'] = customer_id
|
||||||
|
if reference_id:
|
||||||
|
payment_params['reference_id'] = reference_id
|
||||||
|
if note:
|
||||||
|
payment_params['note'] = note
|
||||||
|
|
||||||
|
# Create payment - pass parameters directly as keyword arguments
|
||||||
|
result = self.client.payments.create(**payment_params)
|
||||||
|
|
||||||
if result.errors:
|
if result.errors:
|
||||||
# Payment failed - extract user-friendly error messages
|
# Payment failed - extract user-friendly error messages
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
-- Initialize database with complete schema and default data
|
|
||||||
-- Set character set to UTF-8 to prevent encoding issues
|
|
||||||
SET NAMES utf8mb4;
|
|
||||||
SET CHARACTER SET utf8mb4;
|
|
||||||
|
|
||||||
-- Create all tables
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
hashed_password VARCHAR(255) NOT NULL,
|
|
||||||
first_name VARCHAR(100) NOT NULL,
|
|
||||||
last_name VARCHAR(100) NOT NULL,
|
|
||||||
phone VARCHAR(20),
|
|
||||||
address TEXT,
|
|
||||||
role ENUM('member', 'admin', 'super_admin') NOT NULL DEFAULT 'member',
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
last_login TIMESTAMP NULL,
|
|
||||||
INDEX idx_email (email)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE membership_tiers (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
annual_fee DECIMAL(10,2) NOT NULL,
|
|
||||||
benefits TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE memberships (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
tier_id INT NOT NULL,
|
|
||||||
status ENUM('active', 'expired', 'pending', 'cancelled') NOT NULL DEFAULT 'pending',
|
|
||||||
start_date DATE NOT NULL,
|
|
||||||
end_date DATE NOT NULL,
|
|
||||||
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (tier_id) REFERENCES membership_tiers(id),
|
|
||||||
INDEX idx_user_id (user_id),
|
|
||||||
INDEX idx_tier_id (tier_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE payments (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
membership_id INT,
|
|
||||||
amount DECIMAL(10,2) NOT NULL,
|
|
||||||
payment_method ENUM('square', 'cash', 'check', 'dummy') NOT NULL,
|
|
||||||
status ENUM('pending', 'completed', 'failed', 'refunded') NOT NULL DEFAULT 'pending',
|
|
||||||
transaction_id VARCHAR(255),
|
|
||||||
payment_date TIMESTAMP NULL,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (membership_id) REFERENCES memberships(id),
|
|
||||||
INDEX idx_user_id (user_id),
|
|
||||||
INDEX idx_membership_id (membership_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE events (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
title VARCHAR(255) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
event_date TIMESTAMP NOT NULL,
|
|
||||||
event_time VARCHAR(10), -- HH:MM format
|
|
||||||
location VARCHAR(255),
|
|
||||||
max_attendees INT,
|
|
||||||
status ENUM('draft', 'published', 'cancelled', 'completed') NOT NULL DEFAULT 'draft',
|
|
||||||
created_by INT NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id),
|
|
||||||
INDEX idx_created_by (created_by),
|
|
||||||
INDEX idx_event_date (event_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE event_rsvps (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
event_id INT NOT NULL,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
status ENUM('pending', 'attending', 'not_attending', 'maybe') NOT NULL DEFAULT 'pending',
|
|
||||||
attended BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
UNIQUE KEY unique_event_user (event_id, user_id),
|
|
||||||
INDEX idx_event_id (event_id),
|
|
||||||
INDEX idx_user_id (user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE volunteer_roles (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
name VARCHAR(100) NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE volunteer_assignments (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
role_id INT NOT NULL,
|
|
||||||
assigned_date DATE NOT NULL,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (role_id) REFERENCES volunteer_roles(id),
|
|
||||||
INDEX idx_user_id (user_id),
|
|
||||||
INDEX idx_role_id (role_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE volunteer_schedules (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
assignment_id INT NOT NULL,
|
|
||||||
schedule_date DATE NOT NULL,
|
|
||||||
start_time TIMESTAMP NOT NULL,
|
|
||||||
end_time TIMESTAMP NOT NULL,
|
|
||||||
location VARCHAR(255),
|
|
||||||
notes TEXT,
|
|
||||||
completed BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (assignment_id) REFERENCES volunteer_assignments(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_assignment_id (assignment_id),
|
|
||||||
INDEX idx_schedule_date (schedule_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE certificates (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
certificate_name VARCHAR(255) NOT NULL,
|
|
||||||
issuing_organization VARCHAR(255),
|
|
||||||
issue_date DATE NOT NULL,
|
|
||||||
expiry_date DATE,
|
|
||||||
certificate_number VARCHAR(100),
|
|
||||||
file_path VARCHAR(500),
|
|
||||||
notes TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_user_id (user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE files (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
filename VARCHAR(255) NOT NULL,
|
|
||||||
original_filename VARCHAR(255) NOT NULL,
|
|
||||||
file_path VARCHAR(500) NOT NULL,
|
|
||||||
file_size INT NOT NULL,
|
|
||||||
mime_type VARCHAR(100) NOT NULL,
|
|
||||||
min_tier_id INT,
|
|
||||||
description TEXT,
|
|
||||||
uploaded_by INT NOT NULL,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (min_tier_id) REFERENCES membership_tiers(id),
|
|
||||||
FOREIGN KEY (uploaded_by) REFERENCES users(id),
|
|
||||||
INDEX idx_min_tier_id (min_tier_id),
|
|
||||||
INDEX idx_uploaded_by (uploaded_by)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE notifications (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
subject VARCHAR(255) NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
sent_at TIMESTAMP NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_user_id (user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE password_reset_tokens (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
user_id INT NOT NULL,
|
|
||||||
token VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
expires_at TIMESTAMP NOT NULL,
|
|
||||||
used BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_token (token),
|
|
||||||
INDEX idx_user_id (user_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE email_templates (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
template_key VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
name VARCHAR(255) NOT NULL,
|
|
||||||
subject VARCHAR(255) NOT NULL,
|
|
||||||
html_body TEXT NOT NULL,
|
|
||||||
text_body TEXT,
|
|
||||||
variables TEXT, -- JSON string of available variables
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_template_key (template_key)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE email_bounces (
|
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
email VARCHAR(255) NOT NULL,
|
|
||||||
bounce_type ENUM('hard', 'soft', 'complaint', 'unsubscribe') NOT NULL,
|
|
||||||
bounce_reason VARCHAR(500),
|
|
||||||
smtp2go_message_id VARCHAR(255),
|
|
||||||
bounce_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
||||||
INDEX idx_email (email),
|
|
||||||
INDEX idx_smtp2go_message_id (smtp2go_message_id),
|
|
||||||
INDEX idx_active (is_active)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Seed initial data
|
|
||||||
|
|
||||||
-- Create default membership tiers
|
|
||||||
INSERT INTO membership_tiers (name, description, annual_fee, benefits, is_active, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
('Personal', 'Basic membership for individual members', 5.00, 'Access to member portal, meeting notifications, event participation', TRUE, NOW(), NOW()),
|
|
||||||
('Aircraft Owners', 'Group membership for aircraft owners', 25.00, 'All Personal benefits plus priority event registration, aircraft owner resources', TRUE, NOW(), NOW()),
|
|
||||||
('Corporate', 'Corporate membership for businesses', 100.00, 'All benefits plus corporate recognition, promotional opportunities, file access', TRUE, NOW(), NOW());
|
|
||||||
|
|
||||||
-- Create default admin user (password: admin123)
|
|
||||||
INSERT INTO users (email, hashed_password, first_name, last_name, role, is_active, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
('admin@swanseaairport.org', '$2b$12$eeuS7kW4xUYZPLx4LgBGaeoGhPu/cg/9M3WEanWToTwtLOLppJmzq', 'System', 'Administrator', 'super_admin', TRUE, NOW(), NOW());
|
|
||||||
|
|
||||||
-- Create default email templates
|
|
||||||
INSERT INTO email_templates (template_key, name, subject, html_body, text_body, variables, is_active, created_at, updated_at)
|
|
||||||
VALUES
|
|
||||||
('welcome', 'Welcome Email', 'Welcome to Swansea Airport Stakeholders Alliance - Your Account is Ready',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Welcome to Swansea Airport Stakeholders Alliance!</h2><p>Hello {first_name},</p><p>Thank you for registering with us. Your account has been successfully created.</p><p>You can now:</p><ul><li>Browse membership tiers and select one that suits you</li><li>View upcoming events and meetings</li><li>Access your membership portal</li></ul><p>If you have any questions, please don\'t hesitate to contact us.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
|
|
||||||
'Welcome to Swansea Airport Stakeholders Alliance!\n\nHello {first_name},\n\nThank you for registering with us. Your account has been successfully created.\n\nYou can now:\n- Browse membership tiers and select one that suits you\n- View upcoming events and meetings\n- Access your membership portal\n\nIf you have any questions, please don\'t hesitate to contact us.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
|
|
||||||
'["first_name"]', TRUE, NOW(), NOW()),
|
|
||||||
|
|
||||||
('payment_confirmation', 'Payment Confirmation', 'Payment Confirmation - Swansea Airport Stakeholders Alliance',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #28a745;">Payment Confirmed!</h2><p>Hello {first_name},</p><p>We have received your payment. Thank you!</p><div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"><p style="margin: 5px 0;"><strong>Amount:</strong> {amount}</p><p style="margin: 5px 0;"><strong>Payment Method:</strong> {payment_method}</p><p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p></div><p>Your membership is now active. You can access all the benefits associated with your tier.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
|
|
||||||
'Payment Confirmed!\n\nHello {first_name},\n\nWe have received your payment. Thank you!\n\nAmount: {amount}\nPayment Method: {payment_method}\nMembership Tier: {membership_tier}\n\nYour membership is now active. You can access all the benefits associated with your tier.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
|
|
||||||
'["first_name", "amount", "payment_method", "membership_tier"]', TRUE, NOW(), NOW()),
|
|
||||||
|
|
||||||
('membership_activation', 'Membership Activation', 'Your Swansea Airport Stakeholders Alliance Membership is Now Active!',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #28a745;">Welcome to Swansea Airport Stakeholders Alliance!</h2><p>Hello {first_name},</p><p>Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.</p><div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;"><h3 style="margin-top: 0; color: #28a745;">Membership Details</h3><p style="margin: 8px 0;"><strong>Membership Tier:</strong> {membership_tier}</p><p style="margin: 8px 0;"><strong>Annual Fee:</strong> {annual_fee}</p><p style="margin: 8px 0;"><strong>Next Renewal Date:</strong> {renewal_date}</p></div><div style="background-color: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0;"><h3 style="margin-top: 0; color: #495057;">Payment Information</h3><p style="margin: 8px 0;"><strong>Amount Paid:</strong> {payment_amount}</p><p style="margin: 8px 0;"><strong>Payment Method:</strong> {payment_method}</p><p style="margin: 8px 0;"><strong>Payment Date:</strong> {payment_date}</p></div><p>Your membership will automatically renew on <strong>{renewal_date}</strong> unless you choose to cancel it. You can manage your membership settings in your account dashboard.</p><p>If you have any questions about your membership or need assistance, please don\'t hesitate to contact us.</p><p>Welcome to the Swansea Airport Stakeholders Alliance community!</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance Team</strong></p></body></html>',
|
|
||||||
'Welcome to Swansea Airport Stakeholders Alliance!\n\nHello {first_name},\n\nGreat news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.\n\nMEMBERSHIP DETAILS\n------------------\nMembership Tier: {membership_tier}\nAnnual Fee: {annual_fee}\nNext Renewal Date: {renewal_date}\n\nPAYMENT INFORMATION\n-------------------\nAmount Paid: {payment_amount}\nPayment Method: {payment_method}\nPayment Date: {payment_date}\n\nYour membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.\n\nIf you have any questions about your membership or need assistance, please don\'t hesitate to contact us.\n\nWelcome to the Swansea Airport Stakeholders Alliance community!\n\nBest regards,\nSwansea Airport Stakeholders Alliance Team',
|
|
||||||
'["first_name", "membership_tier", "annual_fee", "payment_amount", "payment_method", "renewal_date", "payment_date"]', TRUE, NOW(), NOW()),
|
|
||||||
|
|
||||||
('renewal_reminder', 'Renewal Reminder', 'Membership Renewal Reminder - Swansea Airport Stakeholders Alliance',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Membership Renewal Reminder</h2><p>Hello {first_name},</p><p>This is a friendly reminder that your <strong>{membership_tier}</strong> membership will expire on <strong>{expiry_date}</strong>.</p><p>To continue enjoying your membership benefits, please renew your membership.</p><div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"><p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p><p style="margin: 5px 0;"><strong>Annual Fee:</strong> {annual_fee}</p><p style="margin: 5px 0;"><strong>Expires:</strong> {expiry_date}</p></div><p>Please log in to your account to renew your membership.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
|
|
||||||
'Membership Renewal Reminder\n\nHello {first_name},\n\nThis is a friendly reminder that your {membership_tier} membership will expire on {expiry_date}.\n\nTo continue enjoying your membership benefits, please renew your membership.\n\nMembership Tier: {membership_tier}\nAnnual Fee: {annual_fee}\nExpires: {expiry_date}\n\nPlease log in to your account to renew your membership.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
|
|
||||||
'["first_name", "expiry_date", "membership_tier", "annual_fee"]', TRUE, NOW(), NOW()),
|
|
||||||
|
|
||||||
('password_reset', 'Password Reset', 'Password Reset Request - Swansea Airport Stakeholders Alliance Member Portal',
|
|
||||||
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Password Reset Request</h2><p>Hello {first_name},</p><p>You have requested to reset your password for your Swansea Airport Stakeholders Alliance member account.</p><p>Please click the button below to reset your password:</p><div style="text-align: center; margin: 30px 0;"><a href="{reset_url}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a></div><p>If the button doesn\'t work, you can copy and paste this link into your browser:</p><p style="word-break: break-all; background-color: #f5f5f5; padding: 10px; border-radius: 3px;">{reset_url}</p><p><strong>This link will expire in 1 hour.</strong></p><p>If you didn\'t request this password reset, please ignore this email. Your password will remain unchanged.</p><p>For security reasons, please don\'t share this email with anyone.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
|
|
||||||
'Password Reset Request\n\nHello {first_name},\n\nYou have requested to reset your password for your Swansea Airport Stakeholders Alliance member account.\n\nPlease use this link to reset your password: {reset_url}\n\nThis link will expire in 1 hour.\n\nIf you didn\'t request this password reset, please ignore this email. Your password will remain unchanged.\n\nFor security reasons, please don\'t share this email with anyone.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
|
|
||||||
'["first_name", "reset_url"]', TRUE, NOW(), NOW());
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
services:
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: development
|
|
||||||
container_name: membership_frontend
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- VITE_HOST_CHECK=false
|
|
||||||
- VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS}
|
|
||||||
ports:
|
|
||||||
- "8050:3000" # Expose frontend to host
|
|
||||||
volumes:
|
|
||||||
- ./frontend/src:/app/src
|
|
||||||
- ./frontend/public:/app/public
|
|
||||||
- ./frontend/vite.config.ts:/app/vite.config.ts
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
networks:
|
|
||||||
- membership_private
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
services:
|
|
||||||
frontend-prod:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
target: production
|
|
||||||
container_name: membership_frontend_prod
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8050:80" # Nginx default port
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
networks:
|
|
||||||
- membership_private
|
|
||||||
@@ -1,53 +1,65 @@
|
|||||||
services:
|
services:
|
||||||
mysql:
|
# mysql:
|
||||||
image: mysql:8.0
|
# image: mysql:8.0
|
||||||
container_name: membership_mysql
|
# container_name: membership_mysql
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
environment:
|
# environment:
|
||||||
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-rootpassword}
|
# MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-secure_root_password_change_this}
|
||||||
MYSQL_DATABASE: ${DATABASE_NAME:-membership_db}
|
# MYSQL_DATABASE: ${DATABASE_NAME:-membership_db}
|
||||||
MYSQL_USER: ${DATABASE_USER:-membership_user}
|
# MYSQL_USER: ${DATABASE_USER:-membership_user}
|
||||||
MYSQL_PASSWORD: ${DATABASE_PASSWORD:-change_this_password}
|
# MYSQL_PASSWORD: ${DATABASE_PASSWORD:-secure_password_change_this}
|
||||||
# No external port exposure - database only accessible on private network
|
# # No external port exposure - database only accessible on private network
|
||||||
expose:
|
# expose:
|
||||||
- "3306"
|
# - "3306"
|
||||||
volumes:
|
# volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
# - mysql_data:/var/lib/mysql
|
||||||
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
# networks:
|
||||||
networks:
|
# - membership_private
|
||||||
- membership_private
|
# healthcheck:
|
||||||
healthcheck:
|
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
# start_period: 10s
|
||||||
start_period: 10s
|
# interval: 5s
|
||||||
interval: 5s
|
# timeout: 5s
|
||||||
timeout: 5s
|
# retries: 10
|
||||||
retries: 10
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: membership_backend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
# Database configuration
|
||||||
|
- DATABASE_HOST=${DATABASE_HOST}
|
||||||
|
- DATABASE_PORT=${DATABASE_PORT}
|
||||||
|
- DATABASE_USER=${DATABASE_USER}
|
||||||
|
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
|
||||||
|
- DATABASE_NAME=${DATABASE_NAME}
|
||||||
|
# Application configuration
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
- ALGORITHM=${ALGORITHM}
|
||||||
|
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES}
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
ports:
|
ports:
|
||||||
- "6000:8000" # Only expose backend API to host
|
- "6000:8000" # Only expose backend API to host
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
|
- ./backend/alembic:/app/alembic
|
||||||
|
- ./backend/alembic.ini:/app/alembic.ini
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
depends_on:
|
command: >
|
||||||
mysql:
|
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||||
condition: service_healthy
|
# depends_on:
|
||||||
networks:
|
# mysql:
|
||||||
- membership_private # Access to database on private network
|
# condition: service_healthy
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: development
|
target: development
|
||||||
container_name: membership_frontend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- VITE_HOST_CHECK=false
|
- VITE_HOST_CHECK=false
|
||||||
@@ -60,8 +72,6 @@ services:
|
|||||||
- ./frontend/vite.config.ts:/app/vite.config.ts
|
- ./frontend/vite.config.ts:/app/vite.config.ts
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
|
||||||
- membership_private
|
|
||||||
|
|
||||||
#frontend-prod:
|
#frontend-prod:
|
||||||
# build:
|
# build:
|
||||||
@@ -77,12 +87,7 @@ services:
|
|||||||
# networks:
|
# networks:
|
||||||
# - membership_private
|
# - membership_private
|
||||||
|
|
||||||
networks:
|
|
||||||
membership_private:
|
|
||||||
driver: bridge
|
|
||||||
internal: false # Allow outbound internet access for backend
|
|
||||||
# Database is not exposed to host - only accessible within this network
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql_data:
|
# mysql_data:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
|
|||||||
@@ -4,8 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>SASA Membership Portal</title>
|
<title>SASA Membership Portal</title>
|
||||||
<!-- Square Web Payments SDK -->
|
<!-- Square Web Payments SDK loaded dynamically based on environment -->
|
||||||
<script type="text/javascript" src="https://sandbox.web.squarecdn.com/v1/square.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -63,6 +63,15 @@ body {
|
|||||||
background-color: #5a6268;
|
background-color: #5a6268;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
@@ -180,11 +189,23 @@ body {
|
|||||||
|
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: 1fr;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Desktop view: side-by-side layout */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid > .card {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile responsive adjustments */
|
/* Mobile responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dashboard-grid {
|
.dashboard-grid {
|
||||||
@@ -449,3 +470,176 @@ body {
|
|||||||
border-color: #c82333;
|
border-color: #c82333;
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Events Container Styles */
|
||||||
|
.events-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
color: #0066cc;
|
||||||
|
font-size: 18px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-datetime {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-location {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 2px solid #adb5bd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #6c757d;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: normal;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn.active {
|
||||||
|
font-weight: bold;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn:not(.active) {
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: grayscale(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn-attending.active {
|
||||||
|
border: 3px solid #28a745;
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn-maybe.active {
|
||||||
|
border: 3px solid #ffc107;
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #212529;
|
||||||
|
box-shadow: 0 4px 8px rgba(255, 193, 7, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn-not-attending.active {
|
||||||
|
border: 3px solid #dc3545;
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 8px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-status {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-status.attending {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-status.maybe {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-status.not_attending {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive adjustments for events */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.event-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-rsvp-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Event modal responsive */
|
||||||
|
.modal-content[style*="600px"] {
|
||||||
|
max-width: 95% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content div[style*="grid-template-columns"] {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { FeatureFlagProvider } from './contexts/FeatureFlagContext';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import ForgotPassword from './pages/ForgotPassword';
|
import ForgotPassword from './pages/ForgotPassword';
|
||||||
@@ -12,19 +13,21 @@ import './App.css';
|
|||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<FeatureFlagProvider>
|
||||||
<Routes>
|
<BrowserRouter>
|
||||||
<Route path="/" element={<Navigate to="/login" />} />
|
<Routes>
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/" element={<Navigate to="/login" />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/reset-password" element={<ResetPassword />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/email-templates" element={<EmailTemplates />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/membership-tiers" element={<MembershipTiers />} />
|
<Route path="/email-templates" element={<EmailTemplates />} />
|
||||||
<Route path="/bounce-management" element={<BounceManagement />} />
|
<Route path="/membership-tiers" element={<MembershipTiers />} />
|
||||||
</Routes>
|
<Route path="/bounce-management" element={<BounceManagement />} />
|
||||||
</BrowserRouter>
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</FeatureFlagProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
80
frontend/src/components/FeatureFlagStatus.tsx
Normal file
80
frontend/src/components/FeatureFlagStatus.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
||||||
|
|
||||||
|
const FeatureFlagStatus: React.FC = () => {
|
||||||
|
const { flags, loading, error, reloadFlags } = useFeatureFlags();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ fontSize: '14px', color: '#666' }}>Loading feature flags...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div style={{ fontSize: '14px', color: '#d32f2f' }}>Error loading feature flags</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flags) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReload = async () => {
|
||||||
|
try {
|
||||||
|
await reloadFlags();
|
||||||
|
console.log('Feature flags reloaded');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reload feature flags:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ marginBottom: '20px' }}>
|
||||||
|
<h4 style={{ marginBottom: '16px' }}>Feature Flags Status</h4>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
|
||||||
|
{Object.entries(flags.flags).map(([name, value]) => (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '8px 12px',
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: '500' }}>
|
||||||
|
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: '500',
|
||||||
|
backgroundColor: value ? '#4CAF50' : '#f44336',
|
||||||
|
color: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={handleReload}
|
||||||
|
style={{ fontSize: '12px', padding: '6px 12px' }}
|
||||||
|
>
|
||||||
|
Reload Flags
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p style={{ fontSize: '12px', color: '#666', marginTop: '12px', marginBottom: 0 }}>
|
||||||
|
Feature flags are loaded from environment variables. Changes require updating the .env file and reloading.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FeatureFlagStatus;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
|
||||||
|
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
|
||||||
import SquarePayment from './SquarePayment';
|
import SquarePayment from './SquarePayment';
|
||||||
|
|
||||||
interface MembershipSetupProps {
|
interface MembershipSetupProps {
|
||||||
@@ -16,6 +17,8 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [createdMembershipId, setCreatedMembershipId] = useState<number | null>(null);
|
const [createdMembershipId, setCreatedMembershipId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { isEnabled } = useFeatureFlags();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTiers();
|
loadTiers();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -202,26 +205,28 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
|
|||||||
<span>→</span>
|
<span>→</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{isEnabled('CASH_PAYMENT_ENABLED') && (
|
||||||
className="btn btn-secondary"
|
<button
|
||||||
onClick={() => handlePaymentMethodSelect('cash')}
|
className="btn btn-secondary"
|
||||||
disabled={loading}
|
onClick={() => handlePaymentMethodSelect('cash')}
|
||||||
style={{
|
disabled={loading}
|
||||||
padding: '16px',
|
style={{
|
||||||
textAlign: 'left',
|
padding: '16px',
|
||||||
display: 'flex',
|
textAlign: 'left',
|
||||||
justifyContent: 'space-between',
|
display: 'flex',
|
||||||
alignItems: 'center'
|
justifyContent: 'space-between',
|
||||||
}}
|
alignItems: 'center'
|
||||||
>
|
}}
|
||||||
<div>
|
>
|
||||||
<strong>Cash Payment</strong>
|
<div>
|
||||||
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
<strong>Cash Payment</strong>
|
||||||
Pay in person or by check
|
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
|
||||||
|
Pay in person or by check
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<span>→</span>
|
||||||
<span>→</span>
|
</button>
|
||||||
</button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { authService } from '../services/membershipService';
|
import { authService, User } from '../services/membershipService';
|
||||||
|
|
||||||
interface ProfileMenuProps {
|
interface ProfileMenuProps {
|
||||||
userName: string;
|
userName: string;
|
||||||
userRole: string;
|
userRole: string;
|
||||||
|
user?: User | null;
|
||||||
|
onEditProfile?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onEditProfile }) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -36,6 +38,14 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const dropdownStyle: React.CSSProperties = {
|
const dropdownStyle: React.CSSProperties = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '100%',
|
top: '100%',
|
||||||
@@ -44,7 +54,8 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
|||||||
border: '1px solid #ddd',
|
border: '1px solid #ddd',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
minWidth: '160px',
|
minWidth: '280px',
|
||||||
|
maxWidth: '320px',
|
||||||
zIndex: 1000,
|
zIndex: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,10 +93,52 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
|||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div style={dropdownStyle}>
|
<div style={dropdownStyle}>
|
||||||
|
{/* Profile Details Section */}
|
||||||
|
{user && (
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid #eee',
|
||||||
|
backgroundColor: '#f9f9f9',
|
||||||
|
borderRadius: '4px 4px 0 0'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: '14px', fontWeight: 'bold', color: '#333' }}>Profile Details</h4>
|
||||||
|
{onEditProfile && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onEditProfile();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: '#0066cc',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '3px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '11px',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#555', lineHeight: '1.6' }}>
|
||||||
|
<p style={{ margin: '4px 0' }}><strong>Name:</strong> {user.first_name} {user.last_name}</p>
|
||||||
|
<p style={{ margin: '4px 0' }}><strong>Email:</strong> {user.email}</p>
|
||||||
|
{user.phone && <p style={{ margin: '4px 0' }}><strong>Phone:</strong> {user.phone}</p>}
|
||||||
|
{user.address && <p style={{ margin: '4px 0' }}><strong>Address:</strong> {user.address}</p>}
|
||||||
|
<p style={{ margin: '4px 0' }}><strong>Member since:</strong> {formatDate(user.created_at)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Menu Items */}
|
||||||
{userRole === 'super_admin' && (
|
{userRole === 'super_admin' && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
style={{ ...menuItemStyle, borderRadius: '4px 4px 0 0' }}
|
style={{ ...menuItemStyle, borderRadius: user ? '0' : '4px 4px 0 0' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigate('/membership-tiers');
|
navigate('/membership-tiers');
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@@ -116,15 +169,15 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole }) => {
|
|||||||
<button
|
<button
|
||||||
style={{
|
style={{
|
||||||
...menuItemStyle,
|
...menuItemStyle,
|
||||||
borderRadius: userRole === 'super_admin' ? '0 0 4px 4px' : '4px 4px 0 0',
|
borderRadius: '0',
|
||||||
borderTop: userRole === 'super_admin' ? '1px solid #eee' : 'none'
|
borderTop: (userRole === 'super_admin' || user) ? '1px solid #eee' : 'none'
|
||||||
}}
|
}}
|
||||||
onClick={handleChangePassword}
|
onClick={handleChangePassword}
|
||||||
>
|
>
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px' }}
|
style={{ ...menuItemStyle, borderRadius: '0 0 4px 4px', borderTop: '1px solid #eee' }}
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
Log Out
|
Log Out
|
||||||
@@ -232,19 +285,33 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="modal-buttons">
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '16px' }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="modal-btn-cancel"
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="modal-btn-primary"
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{loading ? 'Changing...' : 'Change Password'}
|
{loading ? 'Changing...' : 'Change Password'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
|
|||||||
const [payments, setPayments] = useState<any>(null);
|
const [payments, setPayments] = useState<any>(null);
|
||||||
const [squareConfig, setSquareConfig] = useState<any>(null);
|
const [squareConfig, setSquareConfig] = useState<any>(null);
|
||||||
|
|
||||||
|
// Billing details state
|
||||||
|
const [cardholderName, setCardholderName] = useState('');
|
||||||
|
const [addressLine1, setAddressLine1] = useState('');
|
||||||
|
const [addressLine2, setAddressLine2] = useState('');
|
||||||
|
const [city, setCity] = useState('');
|
||||||
|
const [postalCode, setPostalCode] = useState('');
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSquareConfig();
|
loadSquareConfig();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -28,6 +36,31 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
|
|||||||
}
|
}
|
||||||
}, [squareConfig]);
|
}, [squareConfig]);
|
||||||
|
|
||||||
|
const loadSquareSDK = (environment: string): Promise<void> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Check if Square SDK is already loaded
|
||||||
|
if (window.Square) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.type = 'text/javascript';
|
||||||
|
|
||||||
|
// Load the correct SDK based on environment
|
||||||
|
if (environment?.toLowerCase() === 'sandbox') {
|
||||||
|
script.src = 'https://sandbox.web.squarecdn.com/v1/square.js';
|
||||||
|
} else {
|
||||||
|
script.src = 'https://web.squarecdn.com/v1/square.js';
|
||||||
|
}
|
||||||
|
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => reject(new Error('Failed to load Square SDK'));
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadSquareConfig = async () => {
|
const loadSquareConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/payments/config/square', {
|
const response = await fetch('/api/v1/payments/config/square', {
|
||||||
@@ -36,6 +69,12 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const config = await response.json();
|
const config = await response.json();
|
||||||
|
console.log('Square config received:', config);
|
||||||
|
|
||||||
|
// Load the appropriate Square SDK based on environment
|
||||||
|
await loadSquareSDK(config.environment);
|
||||||
|
console.log('Square SDK loaded for environment:', config.environment);
|
||||||
|
|
||||||
setSquareConfig(config);
|
setSquareConfig(config);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load Square config:', error);
|
console.error('Failed to load Square config:', error);
|
||||||
@@ -53,13 +92,42 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Determine environment - default to production if not explicitly set to sandbox
|
||||||
|
const environment = squareConfig.environment?.toLowerCase() === 'sandbox' ? 'sandbox' : 'production';
|
||||||
|
console.log('Initializing Square with environment:', environment);
|
||||||
|
console.log('Application ID:', squareConfig.application_id);
|
||||||
|
console.log('Location ID:', squareConfig.location_id);
|
||||||
|
|
||||||
const paymentsInstance = window.Square.payments(
|
const paymentsInstance = window.Square.payments(
|
||||||
squareConfig.application_id,
|
squareConfig.application_id,
|
||||||
squareConfig.location_id
|
squareConfig.location_id,
|
||||||
|
{
|
||||||
|
environment: environment
|
||||||
|
}
|
||||||
);
|
);
|
||||||
setPayments(paymentsInstance);
|
setPayments(paymentsInstance);
|
||||||
|
|
||||||
const cardInstance = await paymentsInstance.card();
|
// Initialize card without postal code (we collect it separately in billing form)
|
||||||
|
const cardInstance = await paymentsInstance.card({
|
||||||
|
style: {
|
||||||
|
'.input-container': {
|
||||||
|
borderColor: '#E0E0E0',
|
||||||
|
borderRadius: '4px'
|
||||||
|
},
|
||||||
|
'.input-container.is-focus': {
|
||||||
|
borderColor: '#4CAF50'
|
||||||
|
},
|
||||||
|
'.message-text': {
|
||||||
|
color: '#999'
|
||||||
|
},
|
||||||
|
'.message-icon': {
|
||||||
|
color: '#999'
|
||||||
|
},
|
||||||
|
'input': {
|
||||||
|
fontSize: '14px'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
await cardInstance.attach('#card-container');
|
await cardInstance.attach('#card-container');
|
||||||
setCard(cardInstance);
|
setCard(cardInstance);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -76,14 +144,33 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate billing details
|
||||||
|
if (!cardholderName.trim()) {
|
||||||
|
onPaymentError('Please enter cardholder name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!addressLine1.trim()) {
|
||||||
|
onPaymentError('Please enter address line 1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!city.trim()) {
|
||||||
|
onPaymentError('Please enter city');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!postalCode.trim()) {
|
||||||
|
onPaymentError('Please enter postal code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
setIsProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Tokenize the payment method
|
// Tokenize the payment method with billing details
|
||||||
const result = await card.tokenize();
|
const result = await card.tokenize();
|
||||||
|
|
||||||
if (result.status === 'OK') {
|
if (result.status === 'OK') {
|
||||||
// Send the token to your backend
|
// Send the token to your backend with billing details
|
||||||
const response = await fetch('/api/v1/payments/square/process', {
|
const response = await fetch('/api/v1/payments/square/process', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -94,7 +181,15 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
|
|||||||
source_id: result.token,
|
source_id: result.token,
|
||||||
amount: amount,
|
amount: amount,
|
||||||
tier_id: tierId,
|
tier_id: tierId,
|
||||||
note: `Membership payment - £${amount.toFixed(2)}`
|
note: `Membership payment - £${amount.toFixed(2)}`,
|
||||||
|
billing_details: {
|
||||||
|
cardholder_name: cardholderName,
|
||||||
|
address_line_1: addressLine1,
|
||||||
|
address_line_2: addressLine2 || undefined,
|
||||||
|
city: city,
|
||||||
|
postal_code: postalCode,
|
||||||
|
country: 'GB'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -124,33 +219,149 @@ const SquarePayment: React.FC<SquarePaymentProps> = ({
|
|||||||
onPaymentError(error.message || 'Payment processing failed');
|
onPaymentError(error.message || 'Payment processing failed');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: '400px', margin: '0 auto' }}>
|
<div style={{ maxWidth: '500px', margin: '0 auto' }}>
|
||||||
<div style={{ marginBottom: '20px' }}>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
<h4 style={{ marginBottom: '10px' }}>Card Payment</h4>
|
<h4 style={{ marginBottom: '10px' }}>Card Payment</h4>
|
||||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||||
Amount: <strong>£{amount.toFixed(2)}</strong>
|
Amount: <strong>£{amount.toFixed(2)}</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{/* Cardholder Name */}
|
||||||
id="card-container"
|
<div style={{ marginBottom: '16px' }}>
|
||||||
style={{
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
minHeight: '200px',
|
Cardholder Name *
|
||||||
marginBottom: '20px'
|
</label>
|
||||||
}}
|
<input
|
||||||
/>
|
type="text"
|
||||||
|
value={cardholderName}
|
||||||
|
onChange={(e) => setCardholderName(e.target.value)}
|
||||||
|
placeholder="Name as it appears on card"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Line 1 */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Address Line 1 *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addressLine1}
|
||||||
|
onChange={(e) => setAddressLine1(e.target.value)}
|
||||||
|
placeholder="Street address"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Line 2 */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Address Line 2
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={addressLine2}
|
||||||
|
onChange={(e) => setAddressLine2(e.target.value)}
|
||||||
|
placeholder="Apartment, suite, etc. (optional)"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City and Postal Code */}
|
||||||
|
<div style={{ display: 'flex', gap: '12px', marginBottom: '16px' }}>
|
||||||
|
<div style={{ flex: '1' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Town/City *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
placeholder="City"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: '1' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Postcode *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={postalCode}
|
||||||
|
onChange={(e) => setPostalCode(e.target.value.toUpperCase())}
|
||||||
|
placeholder="SW1A 1AA"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '10px 12px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #E0E0E0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !card}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Details */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
Card Details *
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
id="card-container"
|
||||||
|
style={{
|
||||||
|
minHeight: '120px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={handlePayment}
|
onClick={handlePayment}
|
||||||
disabled={isLoading || !card}
|
disabled={isLoading || !card || isProcessing}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%', padding: '12px' }}
|
||||||
>
|
>
|
||||||
{isLoading ? 'Processing...' : `Pay £${amount.toFixed(2)}`}
|
{isProcessing ? 'Processing...' : `Pay £${amount.toFixed(2)}`}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{ marginTop: '16px', fontSize: '12px', color: '#666', textAlign: 'center' }}>
|
<div style={{ marginTop: '16px', fontSize: '12px', color: '#666', textAlign: 'center' }}>
|
||||||
|
|||||||
94
frontend/src/contexts/FeatureFlagContext.tsx
Normal file
94
frontend/src/contexts/FeatureFlagContext.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { featureFlagService, FeatureFlags } from '../services/featureFlagService';
|
||||||
|
|
||||||
|
interface FeatureFlagContextType {
|
||||||
|
flags: FeatureFlags | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isEnabled: (flagName: string) => boolean;
|
||||||
|
getFlagValue: (flagName: string, defaultValue?: any) => any;
|
||||||
|
reloadFlags: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeatureFlagContext = createContext<FeatureFlagContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useFeatureFlags = (): FeatureFlagContextType => {
|
||||||
|
const context = useContext(FeatureFlagContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FeatureFlagProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FeatureFlagProvider: React.FC<FeatureFlagProviderProps> = ({ children }) => {
|
||||||
|
const [flags, setFlags] = useState<FeatureFlags | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadFlags = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const flagsData = await featureFlagService.getAllFlags();
|
||||||
|
setFlags(flagsData);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to load feature flags:', err);
|
||||||
|
setError('Failed to load feature flags');
|
||||||
|
// Set default flags on error
|
||||||
|
setFlags({
|
||||||
|
flags: {
|
||||||
|
CASH_PAYMENT_ENABLED: true,
|
||||||
|
EMAIL_NOTIFICATIONS_ENABLED: true,
|
||||||
|
EVENT_MANAGEMENT_ENABLED: true,
|
||||||
|
AUTO_RENEWAL_ENABLED: false,
|
||||||
|
MEMBERSHIP_TRANSFERS_ENABLED: false,
|
||||||
|
BULK_OPERATIONS_ENABLED: false,
|
||||||
|
ADVANCED_REPORTING_ENABLED: false,
|
||||||
|
API_RATE_LIMITING_ENABLED: true,
|
||||||
|
},
|
||||||
|
enabled_flags: ['CASH_PAYMENT_ENABLED', 'EMAIL_NOTIFICATIONS_ENABLED', 'EVENT_MANAGEMENT_ENABLED', 'API_RATE_LIMITING_ENABLED']
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadFlags = async () => {
|
||||||
|
await loadFlags();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEnabled = (flagName: string): boolean => {
|
||||||
|
if (!flags) return false;
|
||||||
|
const upperFlagName = flagName.toUpperCase();
|
||||||
|
return Boolean(flags.flags[upperFlagName]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFlagValue = (flagName: string, defaultValue: any = null): any => {
|
||||||
|
if (!flags) return defaultValue;
|
||||||
|
const upperFlagName = flagName.toUpperCase();
|
||||||
|
return flags.flags[upperFlagName] ?? defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFlags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue: FeatureFlagContextType = {
|
||||||
|
flags,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
isEnabled,
|
||||||
|
getFlagValue,
|
||||||
|
reloadFlags,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeatureFlagContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</FeatureFlagContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,6 +4,7 @@ import { authService, userService, membershipService, paymentService, eventServi
|
|||||||
import MembershipSetup from '../components/MembershipSetup';
|
import MembershipSetup from '../components/MembershipSetup';
|
||||||
import ProfileMenu from '../components/ProfileMenu';
|
import ProfileMenu from '../components/ProfileMenu';
|
||||||
import ProfileEdit from '../components/ProfileEdit';
|
import ProfileEdit from '../components/ProfileEdit';
|
||||||
|
import FeatureFlagStatus from '../components/FeatureFlagStatus';
|
||||||
|
|
||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -23,9 +24,23 @@ const Dashboard: React.FC = () => {
|
|||||||
const [editFormData, setEditFormData] = useState<Partial<User>>({});
|
const [editFormData, setEditFormData] = useState<Partial<User>>({});
|
||||||
const [upcomingEvents, setUpcomingEvents] = useState<Event[]>([]);
|
const [upcomingEvents, setUpcomingEvents] = useState<Event[]>([]);
|
||||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||||
const [eventRSVPs, setEventRSVPs] = useState<EventRSVP[]>([]);
|
|
||||||
const [eventRSVPCounts, setEventRSVPCounts] = useState<{[eventId: number]: {attending: number, maybe: number, not_attending: number}}>({});
|
const [eventRSVPCounts, setEventRSVPCounts] = useState<{[eventId: number]: {attending: number, maybe: number, not_attending: number}}>({});
|
||||||
const [rsvpLoading, setRsvpLoading] = useState<{[eventId: number]: boolean}>({});
|
const [rsvpLoading, setRsvpLoading] = useState<{[eventId: number]: boolean}>({});
|
||||||
|
const [showEventModal, setShowEventModal] = useState(false);
|
||||||
|
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||||
|
const [eventFormData, setEventFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
event_date: '',
|
||||||
|
event_time: '',
|
||||||
|
location: '',
|
||||||
|
max_attendees: ''
|
||||||
|
});
|
||||||
|
const [showRSVPModal, setShowRSVPModal] = useState(false);
|
||||||
|
const [selectedEventForRSVP, setSelectedEventForRSVP] = useState<Event | null>(null);
|
||||||
|
const [eventRSVPList, setEventRSVPList] = useState<EventRSVP[]>([]);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authService.isAuthenticated()) {
|
if (!authService.isAuthenticated()) {
|
||||||
@@ -171,6 +186,26 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async () => {
|
||||||
|
if (!userToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userService.deleteUser(userToDelete.id);
|
||||||
|
await loadData(); // Reload data to reflect changes
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setUserToDelete(null);
|
||||||
|
alert('User deleted successfully');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to delete user:', error);
|
||||||
|
alert(`Failed to delete user: ${error.response?.data?.detail || error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteUser = (u: User) => {
|
||||||
|
setUserToDelete(u);
|
||||||
|
setShowDeleteConfirm(true);
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusClass = (status: string) => {
|
const getStatusClass = (status: string) => {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
case 'active':
|
case 'active':
|
||||||
@@ -229,6 +264,13 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFormChange = (field: string, value: string) => {
|
||||||
|
setEditFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveUser = async () => {
|
const handleSaveUser = async () => {
|
||||||
if (!selectedUser) return;
|
if (!selectedUser) return;
|
||||||
|
|
||||||
@@ -317,6 +359,100 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCreateEvent = () => {
|
||||||
|
setEditingEvent(null);
|
||||||
|
setEventFormData({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
event_date: '',
|
||||||
|
event_time: '',
|
||||||
|
location: '',
|
||||||
|
max_attendees: ''
|
||||||
|
});
|
||||||
|
setShowEventModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditEvent = (event: Event) => {
|
||||||
|
setEditingEvent(event);
|
||||||
|
|
||||||
|
// Convert event_date to YYYY-MM-DD format for date input
|
||||||
|
const dateObj = new Date(event.event_date);
|
||||||
|
const formattedDate = dateObj.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
setEventFormData({
|
||||||
|
title: event.title,
|
||||||
|
description: event.description || '',
|
||||||
|
event_date: formattedDate,
|
||||||
|
event_time: event.event_time || '',
|
||||||
|
location: event.location || '',
|
||||||
|
max_attendees: event.max_attendees?.toString() || ''
|
||||||
|
});
|
||||||
|
setShowEventModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEventFormChange = (field: string, value: string) => {
|
||||||
|
setEventFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEvent = async () => {
|
||||||
|
try {
|
||||||
|
const eventData = {
|
||||||
|
title: eventFormData.title,
|
||||||
|
description: eventFormData.description || undefined,
|
||||||
|
event_date: eventFormData.event_date,
|
||||||
|
event_time: eventFormData.event_time || undefined,
|
||||||
|
location: eventFormData.location || undefined,
|
||||||
|
max_attendees: eventFormData.max_attendees ? parseInt(eventFormData.max_attendees) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editingEvent) {
|
||||||
|
// Update existing event
|
||||||
|
await eventService.updateEvent(editingEvent.id, eventData);
|
||||||
|
} else {
|
||||||
|
// Create new event
|
||||||
|
await eventService.createEvent(eventData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload events
|
||||||
|
const eventsData = await eventService.getAllEvents();
|
||||||
|
setAllEvents(eventsData);
|
||||||
|
await loadEventRSVPCounts(eventsData);
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
setShowEventModal(false);
|
||||||
|
setEditingEvent(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save event:', error);
|
||||||
|
alert('Failed to save event. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseEventModal = () => {
|
||||||
|
setShowEventModal(false);
|
||||||
|
setEditingEvent(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewRSVPs = async (event: Event) => {
|
||||||
|
setSelectedEventForRSVP(event);
|
||||||
|
try {
|
||||||
|
const rsvps = await eventService.getEventRSVPs(event.id);
|
||||||
|
setEventRSVPList(rsvps);
|
||||||
|
setShowRSVPModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load RSVPs:', error);
|
||||||
|
alert('Failed to load RSVPs. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseRSVPModal = () => {
|
||||||
|
setShowRSVPModal(false);
|
||||||
|
setSelectedEventForRSVP(null);
|
||||||
|
setEventRSVPList([]);
|
||||||
|
};
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleDateString('en-GB', {
|
return new Date(dateString).toLocaleDateString('en-GB', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -352,32 +488,18 @@ const Dashboard: React.FC = () => {
|
|||||||
<>
|
<>
|
||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<h1>SASA Membership Portal</h1>
|
<h1>SASA Membership Portal</h1>
|
||||||
<ProfileMenu userName={`${user?.first_name} ${user?.last_name}`} userRole={user?.role || ''} />
|
<ProfileMenu
|
||||||
|
userName={`${user?.first_name} ${user?.last_name}`}
|
||||||
|
userRole={user?.role || ''}
|
||||||
|
user={user}
|
||||||
|
onEditProfile={handleProfileEdit}
|
||||||
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h2 style={{ marginTop: '20px', marginBottom: '20px' }}>Welcome, {user?.first_name}!</h2>
|
<h2 style={{ marginTop: '20px', marginBottom: '20px' }}>Welcome, {user?.first_name}!</h2>
|
||||||
|
|
||||||
<div className="dashboard-grid">
|
<div className="dashboard-grid">
|
||||||
{/* Profile Card */}
|
|
||||||
<div className="card">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
|
|
||||||
<h3>Your Profile</h3>
|
|
||||||
<button
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={handleProfileEdit}
|
|
||||||
style={{ fontSize: '14px', padding: '6px 12px' }}
|
|
||||||
>
|
|
||||||
Edit Profile
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p><strong>Name:</strong> {user?.first_name} {user?.last_name}</p>
|
|
||||||
<p><strong>Email:</strong> {user?.email}</p>
|
|
||||||
{user?.phone && <p><strong>Phone:</strong> {user.phone}</p>}
|
|
||||||
{user?.address && <p><strong>Address:</strong> {user.address}</p>}
|
|
||||||
<p><strong>Registered since:</strong> {user && formatDate(user.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Membership Card */}
|
{/* Membership Card */}
|
||||||
{activeMembership ? (
|
{activeMembership ? (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@@ -408,6 +530,67 @@ const Dashboard: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Upcoming Events */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 style={{ marginBottom: '16px' }}>Upcoming Events</h3>
|
||||||
|
{upcomingEvents.length > 0 ? (
|
||||||
|
<div className="events-container">
|
||||||
|
{upcomingEvents.map(event => (
|
||||||
|
<div key={event.id} className="event-card">
|
||||||
|
<div className="event-header">
|
||||||
|
<div className="event-info">
|
||||||
|
<h4 className="event-title">{event.title}</h4>
|
||||||
|
<p className="event-datetime">
|
||||||
|
{formatDate(event.event_date)} at {event.event_time}
|
||||||
|
</p>
|
||||||
|
{event.location && (
|
||||||
|
<p className="event-location">
|
||||||
|
📍 {event.location}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="event-rsvp-buttons">
|
||||||
|
<button
|
||||||
|
className={`rsvp-btn rsvp-btn-attending ${event.rsvp_status === 'attending' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleRSVP(event.id, 'attending')}
|
||||||
|
disabled={rsvpLoading[event.id]}
|
||||||
|
>
|
||||||
|
{rsvpLoading[event.id] ? '...' : 'Attending'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`rsvp-btn rsvp-btn-maybe ${event.rsvp_status === 'maybe' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleRSVP(event.id, 'maybe')}
|
||||||
|
disabled={rsvpLoading[event.id]}
|
||||||
|
>
|
||||||
|
{rsvpLoading[event.id] ? '...' : 'Maybe'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`rsvp-btn rsvp-btn-not-attending ${event.rsvp_status === 'not_attending' ? 'active' : ''}`}
|
||||||
|
onClick={() => handleRSVP(event.id, 'not_attending')}
|
||||||
|
disabled={rsvpLoading[event.id]}
|
||||||
|
>
|
||||||
|
{rsvpLoading[event.id] ? '...' : 'Not Attending'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{event.description && (
|
||||||
|
<p className="event-description">
|
||||||
|
{event.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{event.rsvp_status && (
|
||||||
|
<div className={`event-rsvp-status ${event.rsvp_status}`}>
|
||||||
|
<strong>Your RSVP:</strong> <span style={{ textTransform: 'capitalize' }}>{event.rsvp_status.replace('_', ' ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ color: '#666' }}>No upcoming events at this time.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment History */}
|
{/* Payment History */}
|
||||||
@@ -445,125 +628,6 @@ const Dashboard: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Events */}
|
|
||||||
<div className="card" style={{ marginTop: '20px' }}>
|
|
||||||
<h3 style={{ marginBottom: '16px' }}>Upcoming Events</h3>
|
|
||||||
{upcomingEvents.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
|
||||||
{upcomingEvents.map(event => (
|
|
||||||
<div key={event.id} style={{
|
|
||||||
border: '1px solid #ddd',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '16px',
|
|
||||||
backgroundColor: '#f9f9f9'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '12px' }}>
|
|
||||||
<div>
|
|
||||||
<h4 style={{ margin: '0 0 4px 0', color: '#0066cc' }}>{event.title}</h4>
|
|
||||||
<p style={{ margin: '0', fontSize: '14px', color: '#666' }}>
|
|
||||||
{formatDate(event.event_date)} at {event.event_time}
|
|
||||||
</p>
|
|
||||||
{event.location && (
|
|
||||||
<p style={{ margin: '4px 0 0 0', fontSize: '14px', color: '#666' }}>
|
|
||||||
📍 {event.location}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button
|
|
||||||
className={`btn ${event.rsvp_status === 'attending' ? 'btn-success' : 'btn-outline-secondary'}`}
|
|
||||||
onClick={() => handleRSVP(event.id, 'attending')}
|
|
||||||
disabled={rsvpLoading[event.id]}
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
padding: '8px 16px',
|
|
||||||
opacity: rsvpLoading[event.id] ? 0.6 : event.rsvp_status ? (event.rsvp_status === 'attending' ? 1 : 0.4) : 1,
|
|
||||||
cursor: rsvpLoading[event.id] ? 'not-allowed' : 'pointer',
|
|
||||||
fontWeight: event.rsvp_status === 'attending' ? 'bold' : 'normal',
|
|
||||||
border: event.rsvp_status === 'attending' ? '3px solid #28a745' : '2px solid #adb5bd',
|
|
||||||
backgroundColor: event.rsvp_status === 'attending' ? '#28a745' : 'transparent',
|
|
||||||
color: event.rsvp_status === 'attending' ? 'white' : '#6c757d',
|
|
||||||
transform: event.rsvp_status === 'attending' ? 'scale(1.1)' : event.rsvp_status ? 'scale(0.95)' : 'scale(1)',
|
|
||||||
boxShadow: event.rsvp_status === 'attending' ? '0 4px 8px rgba(40, 167, 69, 0.3)' : 'none',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
filter: event.rsvp_status && event.rsvp_status !== 'attending' ? 'grayscale(50%)' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rsvpLoading[event.id] ? '...' : 'Attending'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn ${event.rsvp_status === 'maybe' ? 'btn-warning' : 'btn-outline-secondary'}`}
|
|
||||||
onClick={() => handleRSVP(event.id, 'maybe')}
|
|
||||||
disabled={rsvpLoading[event.id]}
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
padding: '8px 16px',
|
|
||||||
opacity: rsvpLoading[event.id] ? 0.6 : event.rsvp_status ? (event.rsvp_status === 'maybe' ? 1 : 0.4) : 1,
|
|
||||||
cursor: rsvpLoading[event.id] ? 'not-allowed' : 'pointer',
|
|
||||||
fontWeight: event.rsvp_status === 'maybe' ? 'bold' : 'normal',
|
|
||||||
border: event.rsvp_status === 'maybe' ? '3px solid #ffc107' : '2px solid #adb5bd',
|
|
||||||
backgroundColor: event.rsvp_status === 'maybe' ? '#ffc107' : 'transparent',
|
|
||||||
color: event.rsvp_status === 'maybe' ? '#212529' : '#6c757d',
|
|
||||||
transform: event.rsvp_status === 'maybe' ? 'scale(1.1)' : event.rsvp_status ? 'scale(0.95)' : 'scale(1)',
|
|
||||||
boxShadow: event.rsvp_status === 'maybe' ? '0 4px 8px rgba(255, 193, 7, 0.3)' : 'none',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
filter: event.rsvp_status && event.rsvp_status !== 'maybe' ? 'grayscale(50%)' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rsvpLoading[event.id] ? '...' : 'Maybe'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`btn ${event.rsvp_status === 'not_attending' ? 'btn-danger' : 'btn-outline-secondary'}`}
|
|
||||||
onClick={() => handleRSVP(event.id, 'not_attending')}
|
|
||||||
disabled={rsvpLoading[event.id]}
|
|
||||||
style={{
|
|
||||||
fontSize: '12px',
|
|
||||||
padding: '8px 16px',
|
|
||||||
opacity: rsvpLoading[event.id] ? 0.6 : event.rsvp_status ? (event.rsvp_status === 'not_attending' ? 1 : 0.4) : 1,
|
|
||||||
cursor: rsvpLoading[event.id] ? 'not-allowed' : 'pointer',
|
|
||||||
fontWeight: event.rsvp_status === 'not_attending' ? 'bold' : 'normal',
|
|
||||||
border: event.rsvp_status === 'not_attending' ? '3px solid #dc3545' : '2px solid #adb5bd',
|
|
||||||
backgroundColor: event.rsvp_status === 'not_attending' ? '#dc3545' : 'transparent',
|
|
||||||
color: event.rsvp_status === 'not_attending' ? 'white' : '#6c757d',
|
|
||||||
transform: event.rsvp_status === 'not_attending' ? 'scale(1.1)' : event.rsvp_status ? 'scale(0.95)' : 'scale(1)',
|
|
||||||
boxShadow: event.rsvp_status === 'not_attending' ? '0 4px 8px rgba(220, 53, 69, 0.3)' : 'none',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
filter: event.rsvp_status && event.rsvp_status !== 'not_attending' ? 'grayscale(50%)' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{rsvpLoading[event.id] ? '...' : 'Not Attending'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{event.description && (
|
|
||||||
<p style={{ margin: '0', fontSize: '14px', lineHeight: '1.4' }}>
|
|
||||||
{event.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{event.rsvp_status && (
|
|
||||||
<div style={{
|
|
||||||
marginTop: '12px',
|
|
||||||
padding: '8px 12px',
|
|
||||||
backgroundColor: event.rsvp_status === 'attending' ? '#d4edda' :
|
|
||||||
event.rsvp_status === 'maybe' ? '#fff3cd' : '#f8d7da',
|
|
||||||
border: `1px solid ${event.rsvp_status === 'attending' ? '#c3e6cb' :
|
|
||||||
event.rsvp_status === 'maybe' ? '#ffeaa7' : '#f5c6cb'}`,
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px',
|
|
||||||
color: event.rsvp_status === 'attending' ? '#155724' :
|
|
||||||
event.rsvp_status === 'maybe' ? '#856404' : '#721c24'
|
|
||||||
}}>
|
|
||||||
<strong>Your RSVP:</strong> <span style={{ textTransform: 'capitalize' }}>{event.rsvp_status.replace('_', ' ')}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p style={{ color: '#666' }}>No upcoming events at this time.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin Section */}
|
{/* Admin Section */}
|
||||||
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
||||||
<div className="card" style={{ marginTop: '20px' }}>
|
<div className="card" style={{ marginTop: '20px' }}>
|
||||||
@@ -653,6 +717,11 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Feature Flag Management - Super Admin Only */}
|
||||||
|
{user?.role === 'super_admin' && (
|
||||||
|
<FeatureFlagStatus />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User Management Section */}
|
{/* User Management Section */}
|
||||||
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
{(user?.role === 'admin' || user?.role === 'super_admin') && (
|
||||||
<div className="card" style={{ marginTop: '20px' }}>
|
<div className="card" style={{ marginTop: '20px' }}>
|
||||||
@@ -747,6 +816,18 @@ const Dashboard: React.FC = () => {
|
|||||||
{u.role === 'super_admin' && (
|
{u.role === 'super_admin' && (
|
||||||
<span style={{ fontSize: '12px', color: '#666' }}>Super Admin</span>
|
<span style={{ fontSize: '12px', color: '#666' }}>Super Admin</span>
|
||||||
)}
|
)}
|
||||||
|
{user?.role === 'super_admin' && u.id !== user?.id && (
|
||||||
|
<button
|
||||||
|
className="btn btn-danger"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // Prevent row click
|
||||||
|
confirmDeleteUser(u);
|
||||||
|
}}
|
||||||
|
style={{ fontSize: '12px', padding: '4px 8px', marginLeft: '4px' }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -775,7 +856,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => {/* TODO: Implement create event modal */}}
|
onClick={handleCreateEvent}
|
||||||
style={{ fontSize: '14px', padding: '8px 16px' }}
|
style={{ fontSize: '14px', padding: '8px 16px' }}
|
||||||
>
|
>
|
||||||
Create New Event
|
Create New Event
|
||||||
@@ -797,7 +878,11 @@ const Dashboard: React.FC = () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{allEvents.map(event => (
|
{allEvents.map(event => (
|
||||||
<tr key={event.id} style={{ borderBottom: '1px solid #eee' }}>
|
<tr
|
||||||
|
key={event.id}
|
||||||
|
style={{ borderBottom: '1px solid #eee', cursor: 'pointer' }}
|
||||||
|
onClick={() => handleViewRSVPs(event)}
|
||||||
|
>
|
||||||
<td style={{ padding: '12px' }}>
|
<td style={{ padding: '12px' }}>
|
||||||
<div>
|
<div>
|
||||||
<strong>{event.title}</strong>
|
<strong>{event.title}</strong>
|
||||||
@@ -833,7 +918,10 @@ const Dashboard: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', gap: '4px' }}>
|
<div style={{ display: 'flex', gap: '4px' }}>
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => {/* TODO: Implement edit event */}}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEditEvent(event);
|
||||||
|
}}
|
||||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
@@ -841,7 +929,10 @@ const Dashboard: React.FC = () => {
|
|||||||
{event.status === 'draft' && (
|
{event.status === 'draft' && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => handlePublishEvent(event.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePublishEvent(event.id);
|
||||||
|
}}
|
||||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
>
|
>
|
||||||
Publish
|
Publish
|
||||||
@@ -850,7 +941,10 @@ const Dashboard: React.FC = () => {
|
|||||||
{event.status === 'published' && (
|
{event.status === 'published' && (
|
||||||
<button
|
<button
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => handleCancelEvent(event.id)}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCancelEvent(event.id);
|
||||||
|
}}
|
||||||
style={{ fontSize: '12px', padding: '4px 8px' }}
|
style={{ fontSize: '12px', padding: '4px 8px' }}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -1114,6 +1208,248 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Event Create/Edit Modal */}
|
||||||
|
{showEventModal && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal-content" style={{ maxWidth: '600px' }}>
|
||||||
|
<h3>{editingEvent ? 'Edit Event' : 'Create New Event'}</h3>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Event Title *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eventFormData.title}
|
||||||
|
onChange={(e) => handleEventFormChange('title', e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Annual General Meeting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea
|
||||||
|
value={eventFormData.description}
|
||||||
|
onChange={(e) => handleEventFormChange('description', e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '8px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '16px',
|
||||||
|
resize: 'vertical'
|
||||||
|
}}
|
||||||
|
placeholder="Event details and agenda..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Event Date *</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={eventFormData.event_date}
|
||||||
|
onChange={(e) => handleEventFormChange('event_date', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Event Time</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={eventFormData.event_time}
|
||||||
|
onChange={(e) => handleEventFormChange('event_time', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Location</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={eventFormData.location}
|
||||||
|
onChange={(e) => handleEventFormChange('location', e.target.value)}
|
||||||
|
placeholder="Swansea Airport Conference Room"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-form-group">
|
||||||
|
<label>Max Attendees (optional)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={eventFormData.max_attendees}
|
||||||
|
onChange={(e) => handleEventFormChange('max_attendees', e.target.value)}
|
||||||
|
min="1"
|
||||||
|
placeholder="Leave blank for unlimited"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCloseEventModal}
|
||||||
|
className="modal-btn-cancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveEvent}
|
||||||
|
className="modal-btn-primary"
|
||||||
|
disabled={!eventFormData.title || !eventFormData.event_date}
|
||||||
|
>
|
||||||
|
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* RSVP List Modal */}
|
||||||
|
{showRSVPModal && selectedEventForRSVP && (
|
||||||
|
<div className="modal-overlay" onClick={handleCloseRSVPModal}>
|
||||||
|
<div
|
||||||
|
className="modal-content"
|
||||||
|
style={{ maxWidth: '700px', maxHeight: '80vh', overflow: 'auto' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||||
|
<h3 style={{ margin: 0 }}>RSVPs for {selectedEventForRSVP.title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleCloseRSVPModal}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#666'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px', padding: '12px', backgroundColor: '#f5f5f5', borderRadius: '4px' }}>
|
||||||
|
<p style={{ margin: '4px 0', fontSize: '14px' }}><strong>Date:</strong> {formatDate(selectedEventForRSVP.event_date)}</p>
|
||||||
|
{selectedEventForRSVP.event_time && (
|
||||||
|
<p style={{ margin: '4px 0', fontSize: '14px' }}><strong>Time:</strong> {selectedEventForRSVP.event_time}</p>
|
||||||
|
)}
|
||||||
|
{selectedEventForRSVP.location && (
|
||||||
|
<p style={{ margin: '4px 0', fontSize: '14px' }}><strong>Location:</strong> {selectedEventForRSVP.location}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eventRSVPList.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '16px', display: 'flex', gap: '16px', fontSize: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<strong>Attending:</strong> {eventRSVPList.filter(r => r.status === 'attending').length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Maybe:</strong> {eventRSVPList.filter(r => r.status === 'maybe').length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Not Attending:</strong> {eventRSVPList.filter(r => r.status === 'not_attending').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="table-container">
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #ddd' }}>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Name</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Email</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>RSVP</th>
|
||||||
|
<th style={{ padding: '12px', textAlign: 'left' }}>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{eventRSVPList.map(rsvp => {
|
||||||
|
const rsvpUser = allUsers.find(u => u.id === rsvp.user_id);
|
||||||
|
return (
|
||||||
|
<tr key={rsvp.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
{rsvpUser ? `${rsvpUser.first_name} ${rsvpUser.last_name}` : `User #${rsvp.user_id}`}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
{rsvpUser?.email || 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px' }}>
|
||||||
|
<span className={`status-badge ${
|
||||||
|
rsvp.status === 'attending' ? 'status-active' :
|
||||||
|
rsvp.status === 'maybe' ? 'status-pending' :
|
||||||
|
'status-expired'
|
||||||
|
}`}>
|
||||||
|
{rsvp.status === 'attending' ? 'ATTENDING' :
|
||||||
|
rsvp.status === 'maybe' ? 'MAYBE' :
|
||||||
|
'NOT ATTENDING'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: '12px', fontSize: '12px', color: '#666' }}>
|
||||||
|
{rsvp.created_at ? formatDate(rsvp.created_at) : 'N/A'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p style={{ textAlign: 'center', color: '#666', padding: '20px' }}>No RSVPs yet for this event.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete User Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && userToDelete && (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<div className="modal-content">
|
||||||
|
<h3 style={{ color: '#dc3545' }}>Delete User</h3>
|
||||||
|
<p>Are you sure you want to delete the user <strong>{userToDelete.first_name} {userToDelete.last_name}</strong> ({userToDelete.email})?</p>
|
||||||
|
<p style={{ color: '#dc3545', fontSize: '14px' }}>
|
||||||
|
⚠️ This action cannot be undone. All associated memberships and payments will also be deleted.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
setUserToDelete(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#6c757d',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDeleteUser}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#dc3545',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
29
frontend/src/services/featureFlagService.ts
Normal file
29
frontend/src/services/featureFlagService.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import api from './api';
|
||||||
|
|
||||||
|
export interface FeatureFlag {
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
value: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureFlags {
|
||||||
|
flags: { [key: string]: any };
|
||||||
|
enabled_flags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const featureFlagService = {
|
||||||
|
async getAllFlags(): Promise<FeatureFlags> {
|
||||||
|
const response = await api.get('/feature-flags/flags');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFlag(flagName: string): Promise<FeatureFlag> {
|
||||||
|
const response = await api.get(`/feature-flags/flags/${flagName}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadFlags(): Promise<{ message: string }> {
|
||||||
|
const response = await api.post('/feature-flags/flags/reload');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -225,6 +225,11 @@ export const userService = {
|
|||||||
const response = await api.put(`/users/${userId}`, data);
|
const response = await api.put(`/users/${userId}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteUser(userId: number): Promise<{ message: string }> {
|
||||||
|
const response = await api.delete(`/users/${userId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const membershipService = {
|
export const membershipService = {
|
||||||
|
|||||||
Reference in New Issue
Block a user