From 13cca9c029b2b483f13fcb6385dc9d6c72eed359 Mon Sep 17 00:00:00 2001 From: Anthony Wright Date: Thu, 4 Jun 2026 07:34:25 -0500 Subject: [PATCH 1/2] Implement premium care marketplace and mobile UI modernization --- .gitignore | 4 +- README.md | 1 + .../a9d8f2c6b1e4_add_care_categories.py | 256 +++++ apps/api/app/core/brand.py | 2 + apps/api/app/main.py | 2 + apps/api/app/models/__init__.py | 7 + apps/api/app/models/appointment.py | 23 + apps/api/app/models/care_category.py | 92 ++ apps/api/app/models/company_care_category.py | 26 + apps/api/app/models/provider_care_category.py | 26 + apps/api/app/models/service.py | 25 +- apps/api/app/routers/__init__.py | 3 +- apps/api/app/routers/appointments.py | 77 +- apps/api/app/routers/care_categories.py | 24 + apps/api/app/routers/companies.py | 60 +- apps/api/app/routers/company_ops.py | 53 +- apps/api/app/routers/dev_seed.py | 194 +++- apps/api/app/routers/payment_return.py | 9 +- apps/api/app/routers/services.py | 20 +- apps/api/app/schemas/appointment.py | 20 + apps/api/app/schemas/care_category.py | 32 + apps/api/app/schemas/company.py | 5 +- apps/api/app/schemas/service.py | 8 + apps/api/app/services/live_events.py | 6 + apps/api/app/services/notifications.py | 5 + apps/api/app/services/pricing.py | 8 + apps/api/scripts/seed_services.py | 90 +- apps/api/tests/test_dev_seed.py | 135 ++- .../test_premium_care_marketplace_flows.py | 318 ++++++ apps/api/tests/test_services.py | 176 ++++ apps/mobile/.easignore | 4 +- apps/mobile/app.config.ts | 12 +- apps/mobile/eas.json | 4 +- .../appointmentCategoryNeutralCopy.test.ts | 20 + .../src/__tests__/bookingCheckout.test.ts | 4 + .../customerNotificationsGrouping.test.ts | 13 + apps/mobile/src/__tests__/demoLogins.test.ts | 50 +- apps/mobile/src/api/http.test.ts | 40 + apps/mobile/src/api/http.ts | 40 +- apps/mobile/src/api/services.ts | 4 + apps/mobile/src/auth/demoLogins.ts | 87 ++ apps/mobile/src/components/AppButton.tsx | 1 + apps/mobile/src/components/AppCard.tsx | 1 + apps/mobile/src/components/AppScreen.tsx | 1 + .../mobile/src/components/AppointmentCard.tsx | 319 +++--- .../src/components/AppointmentTimeline.tsx | 2 + .../src/components/CustomerTravelMapCard.tsx | 62 +- apps/mobile/src/components/EmptyState.tsx | 1 + apps/mobile/src/components/LoadingState.tsx | 1 + apps/mobile/src/components/OwnerJobCard.tsx | 221 ++-- apps/mobile/src/components/ProviderCard.tsx | 115 +- .../mobile/src/components/ScreenContainer.tsx | 2 +- apps/mobile/src/components/SearchBar.tsx | 17 +- apps/mobile/src/components/SectionHeader.tsx | 1 + apps/mobile/src/components/ServiceCard.tsx | 138 ++- apps/mobile/src/components/StatusBadge.tsx | 1 + apps/mobile/src/components/TravelMapCard.tsx | 58 +- .../appointmentLuxuryCards.test.tsx | 71 ++ .../src/components/bookingStepper.test.tsx | 32 + .../src/components/categoryMetadata.test.ts | 41 + .../components/luxuryDiscoveryCards.test.tsx | 118 +++ .../components/providerAdminLuxury.test.tsx | 91 ++ .../src/components/rewardsCard.test.tsx | 31 + apps/mobile/src/components/ui/AppButton.tsx | 2 + apps/mobile/src/components/ui/AppCard.tsx | 2 + apps/mobile/src/components/ui/AppScreen.tsx | 87 ++ .../src/components/ui/AppointmentTimeline.tsx | 136 +++ .../src/components/ui/BookingStepper.tsx | 100 ++ apps/mobile/src/components/ui/Button.tsx | 132 ++- apps/mobile/src/components/ui/Card.tsx | 81 +- .../mobile/src/components/ui/CategoryChip.tsx | 17 +- .../mobile/src/components/ui/CategoryTile.tsx | 84 ++ apps/mobile/src/components/ui/EmptyState.tsx | 73 ++ .../mobile/src/components/ui/LoadingState.tsx | 48 + .../src/components/ui/MediaPlaceholder.tsx | 107 ++ apps/mobile/src/components/ui/RewardsCard.tsx | 102 ++ apps/mobile/src/components/ui/SearchBar.tsx | 18 +- .../src/components/ui/SectionHeader.tsx | 69 ++ apps/mobile/src/components/ui/StatusBadge.tsx | 97 ++ apps/mobile/src/components/ui/Text.tsx | 35 +- apps/mobile/src/content/brandCopy.ts | 8 + .../src/discovery/categoryDiscovery.test.ts | 125 +++ .../mobile/src/discovery/categoryDiscovery.ts | 48 + apps/mobile/src/discovery/categoryMetadata.ts | 13 + .../src/discovery/marketplaceVisuals.test.ts | 28 + .../src/discovery/marketplaceVisuals.ts | 70 ++ apps/mobile/src/features/appointmentCopy.ts | 35 + apps/mobile/src/features/providerAdminCopy.ts | 99 ++ .../src/hooks/useCustomerNotifications.ts | 16 +- apps/mobile/src/navigation/RootTabs.tsx | 26 +- .../src/navigation/rootTabsOptions.test.ts | 8 + apps/mobile/src/navigation/types.ts | 2 +- .../appointments/AppointmentListScreen.tsx | 170 +-- apps/mobile/src/screens/auth/LoginScreen.tsx | 377 ++++--- .../customer/AppointmentDetailScreen.tsx | 667 +++++------- .../customer/CustomerNotificationsScreen.tsx | 429 ++++---- .../screens/customer/PaymentResultScreen.tsx | 230 ++-- .../src/screens/home/BookingConfirmScreen.tsx | 184 ++-- .../src/screens/home/BookingDateScreen.tsx | 127 ++- .../screens/home/BookingReviewPayScreen.tsx | 196 ++-- .../src/screens/home/BookingTimeScreen.tsx | 142 ++- .../src/screens/home/HomeScreen.test.tsx | 194 ++++ apps/mobile/src/screens/home/HomeScreen.tsx | 989 ++++++++++++++++-- .../src/screens/home/ProviderMenuScreen.tsx | 143 ++- .../src/screens/home/ServiceDetailScreen.tsx | 173 ++- .../owner/OwnerAppointmentDetailScreen.tsx | 229 ++-- .../screens/owner/OwnerDashboardScreen.tsx | 582 ++++++----- .../src/screens/profile/ProfileScreen.tsx | 126 ++- .../ProviderAppointmentDetailScreen.tsx | 407 +++---- .../provider/ProviderDashboardScreen.tsx | 694 ++++++------ apps/mobile/src/theme/theme.ts | 252 ++++- apps/mobile/src/types/booking.ts | 23 + apps/mobile/src/types/care.ts | 11 + apps/mobile/src/types/company.ts | 7 + .../.openspec.yaml | 2 + .../design.md | 204 ++++ .../phase-1-audit.md | 51 + .../proposal.md | 60 ++ .../qa-checklist.md | 110 ++ .../spec.md | 177 ++++ .../tasks.md | 69 ++ .../.openspec.yaml | 2 + .../polish-mobile-luxury-care-ui/design.md | 89 ++ .../polish-mobile-luxury-care-ui/proposal.md | 35 + .../qa-checklist.md | 53 + .../mobile-luxury-care-ui-experience/spec.md | 144 +++ .../polish-mobile-luxury-care-ui/tasks.md | 76 ++ .../.openspec.yaml | 2 + .../design.md | 120 +++ .../phase-1-implementation-notes.md | 14 + .../proposal.md | 31 + .../qa-checklist.md | 55 + .../specs/mobile-modern-ui-experience/spec.md | 139 +++ .../tasks.md | 57 + scripts/start-local.ps1 | 3 +- scripts/start-mobile.ps1 | 4 + 136 files changed, 9975 insertions(+), 2852 deletions(-) create mode 100644 apps/api/alembic/versions/a9d8f2c6b1e4_add_care_categories.py create mode 100644 apps/api/app/core/brand.py create mode 100644 apps/api/app/models/care_category.py create mode 100644 apps/api/app/models/company_care_category.py create mode 100644 apps/api/app/models/provider_care_category.py create mode 100644 apps/api/app/routers/care_categories.py create mode 100644 apps/api/app/schemas/care_category.py create mode 100644 apps/api/tests/test_premium_care_marketplace_flows.py create mode 100644 apps/mobile/src/__tests__/appointmentCategoryNeutralCopy.test.ts create mode 100644 apps/mobile/src/api/http.test.ts create mode 100644 apps/mobile/src/components/AppButton.tsx create mode 100644 apps/mobile/src/components/AppCard.tsx create mode 100644 apps/mobile/src/components/AppScreen.tsx create mode 100644 apps/mobile/src/components/AppointmentTimeline.tsx create mode 100644 apps/mobile/src/components/EmptyState.tsx create mode 100644 apps/mobile/src/components/LoadingState.tsx create mode 100644 apps/mobile/src/components/SectionHeader.tsx create mode 100644 apps/mobile/src/components/StatusBadge.tsx create mode 100644 apps/mobile/src/components/appointmentLuxuryCards.test.tsx create mode 100644 apps/mobile/src/components/bookingStepper.test.tsx create mode 100644 apps/mobile/src/components/categoryMetadata.test.ts create mode 100644 apps/mobile/src/components/luxuryDiscoveryCards.test.tsx create mode 100644 apps/mobile/src/components/providerAdminLuxury.test.tsx create mode 100644 apps/mobile/src/components/rewardsCard.test.tsx create mode 100644 apps/mobile/src/components/ui/AppButton.tsx create mode 100644 apps/mobile/src/components/ui/AppCard.tsx create mode 100644 apps/mobile/src/components/ui/AppScreen.tsx create mode 100644 apps/mobile/src/components/ui/AppointmentTimeline.tsx create mode 100644 apps/mobile/src/components/ui/BookingStepper.tsx create mode 100644 apps/mobile/src/components/ui/CategoryTile.tsx create mode 100644 apps/mobile/src/components/ui/EmptyState.tsx create mode 100644 apps/mobile/src/components/ui/LoadingState.tsx create mode 100644 apps/mobile/src/components/ui/MediaPlaceholder.tsx create mode 100644 apps/mobile/src/components/ui/RewardsCard.tsx create mode 100644 apps/mobile/src/components/ui/SectionHeader.tsx create mode 100644 apps/mobile/src/components/ui/StatusBadge.tsx create mode 100644 apps/mobile/src/content/brandCopy.ts create mode 100644 apps/mobile/src/discovery/categoryDiscovery.test.ts create mode 100644 apps/mobile/src/discovery/categoryDiscovery.ts create mode 100644 apps/mobile/src/discovery/categoryMetadata.ts create mode 100644 apps/mobile/src/discovery/marketplaceVisuals.test.ts create mode 100644 apps/mobile/src/discovery/marketplaceVisuals.ts create mode 100644 apps/mobile/src/features/appointmentCopy.ts create mode 100644 apps/mobile/src/features/providerAdminCopy.ts create mode 100644 apps/mobile/src/navigation/rootTabsOptions.test.ts create mode 100644 apps/mobile/src/screens/home/HomeScreen.test.tsx create mode 100644 apps/mobile/src/types/care.ts create mode 100644 openspec/changes/evolve-to-premium-care-marketplace/.openspec.yaml create mode 100644 openspec/changes/evolve-to-premium-care-marketplace/design.md create mode 100644 openspec/changes/evolve-to-premium-care-marketplace/phase-1-audit.md create mode 100644 openspec/changes/evolve-to-premium-care-marketplace/proposal.md create mode 100644 openspec/changes/evolve-to-premium-care-marketplace/qa-checklist.md create mode 100644 openspec/changes/evolve-to-premium-care-marketplace/specs/premium-care-marketplace-categories/spec.md create mode 100644 openspec/changes/evolve-to-premium-care-marketplace/tasks.md create mode 100644 openspec/changes/polish-mobile-luxury-care-ui/.openspec.yaml create mode 100644 openspec/changes/polish-mobile-luxury-care-ui/design.md create mode 100644 openspec/changes/polish-mobile-luxury-care-ui/proposal.md create mode 100644 openspec/changes/polish-mobile-luxury-care-ui/qa-checklist.md create mode 100644 openspec/changes/polish-mobile-luxury-care-ui/specs/mobile-luxury-care-ui-experience/spec.md create mode 100644 openspec/changes/polish-mobile-luxury-care-ui/tasks.md create mode 100644 openspec/changes/polish-mobile-modern-ui-experience/.openspec.yaml create mode 100644 openspec/changes/polish-mobile-modern-ui-experience/design.md create mode 100644 openspec/changes/polish-mobile-modern-ui-experience/phase-1-implementation-notes.md create mode 100644 openspec/changes/polish-mobile-modern-ui-experience/proposal.md create mode 100644 openspec/changes/polish-mobile-modern-ui-experience/qa-checklist.md create mode 100644 openspec/changes/polish-mobile-modern-ui-experience/specs/mobile-modern-ui-experience/spec.md create mode 100644 openspec/changes/polish-mobile-modern-ui-experience/tasks.md diff --git a/.gitignore b/.gitignore index e8b4b7a..bcde247 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,6 @@ __pycache__/ apps/api/app/static/ apps/api/app/static/uploads/ -dev.db \ No newline at end of file +dev.db +.local-logs/ +.worktrees/ diff --git a/README.md b/README.md index f346dd7..9e50965 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ Useful options: - `-ApiBaseUrl "http://10.0.2.2:8000"` for Android emulator - `-ApiBaseUrl "http://:8000"` for a physical device +- `-DemoMarket shelby` or `-DemoMarket mt_juliet` to choose which demo login buttons Expo displays - `-ExpectedPaymentMode service` to fail fast if the API is not actually running in service payment mode - `-Tunnel` to run `npm start -- --tunnel` - `-SkipApiCheck` to skip the preflight `GET /health` check diff --git a/apps/api/alembic/versions/a9d8f2c6b1e4_add_care_categories.py b/apps/api/alembic/versions/a9d8f2c6b1e4_add_care_categories.py new file mode 100644 index 0000000..beac04e --- /dev/null +++ b/apps/api/alembic/versions/a9d8f2c6b1e4_add_care_categories.py @@ -0,0 +1,256 @@ +"""add care categories + +Revision ID: a9d8f2c6b1e4 +Revises: f7c1e2d3a4b5 +Create Date: 2026-06-03 00:00:00.000000 +""" + +from __future__ import annotations + +import uuid + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision = "a9d8f2c6b1e4" +down_revision = "f7c1e2d3a4b5" +branch_labels = None +depends_on = None + + +SHOES_CATEGORY_ID = uuid.UUID("11111111-1111-4111-8111-111111111111") +BASELINE_CATEGORIES = ( + { + "id": SHOES_CATEGORY_ID, + "slug": "shoes", + "name": "Shoes", + "description": "Premium cleaning, restoration, and pickup care for sneakers and footwear.", + "sort_order": 10, + "icon_key": "footprints", + "hero_image_url": None, + "is_active": True, + }, + { + "id": uuid.UUID("22222222-2222-4222-8222-222222222222"), + "slug": "laundry", + "name": "Laundry", + "description": "Wash, fold, pickup, and delivery care for everyday garments and linens.", + "sort_order": 20, + "icon_key": "shirt", + "hero_image_url": None, + "is_active": True, + }, + { + "id": uuid.UUID("33333333-3333-4333-8333-333333333333"), + "slug": "dry-cleaning", + "name": "Dry Cleaning", + "description": "Professional care for delicate garments, suits, dresses, and formal wear.", + "sort_order": 30, + "icon_key": "sparkles", + "hero_image_url": None, + "is_active": True, + }, + { + "id": uuid.UUID("44444444-4444-4444-8444-444444444444"), + "slug": "handbags-leather", + "name": "Handbags & Leather", + "description": "Specialist cleaning and conditioning for handbags, leather goods, and accessories.", + "sort_order": 40, + "icon_key": "briefcase", + "hero_image_url": None, + "is_active": True, + }, + { + "id": uuid.UUID("55555555-5555-4555-8555-555555555555"), + "slug": "rugs-textiles", + "name": "Rugs & Textiles", + "description": "Premium care for rugs, home textiles, and delicate fabric items.", + "sort_order": 50, + "icon_key": "layout-grid", + "hero_image_url": None, + "is_active": True, + }, + { + "id": uuid.UUID("66666666-6666-4666-8666-666666666666"), + "slug": "alterations", + "name": "Alterations", + "description": "Tailoring, fit adjustments, repairs, and garment finishing services.", + "sort_order": 60, + "icon_key": "scissors", + "hero_image_url": None, + "is_active": True, + }, +) + + +def _table_exists(table_name: str) -> bool: + return table_name in sa.inspect(op.get_bind()).get_table_names() + + +def _column_exists(table_name: str, column_name: str) -> bool: + if not _table_exists(table_name): + return False + return any(column["name"] == column_name for column in sa.inspect(op.get_bind()).get_columns(table_name)) + + +def _index_exists(table_name: str, index_name: str) -> bool: + if not _table_exists(table_name): + return False + return any(index["name"] == index_name for index in sa.inspect(op.get_bind()).get_indexes(table_name)) + + +def _fk_exists(table_name: str, fk_name: str) -> bool: + if not _table_exists(table_name): + return False + return any(fk["name"] == fk_name for fk in sa.inspect(op.get_bind()).get_foreign_keys(table_name)) + + +def _uuid_type() -> sa.types.TypeEngine: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + return postgresql.UUID(as_uuid=True) + return sa.String(length=36) + + +def _seed_categories() -> None: + care_categories = sa.table( + "care_categories", + sa.column("id", _uuid_type()), + sa.column("slug", sa.String), + sa.column("name", sa.String), + sa.column("description", sa.Text), + sa.column("sort_order", sa.Integer), + sa.column("icon_key", sa.String), + sa.column("hero_image_url", sa.String), + sa.column("is_active", sa.Boolean), + ) + bind = op.get_bind() + existing_slugs = { + row[0] + for row in bind.execute(sa.text("SELECT slug FROM care_categories WHERE slug IN :slugs").bindparams( + sa.bindparam("slugs", expanding=True), + ), {"slugs": [category["slug"] for category in BASELINE_CATEGORIES]}) + } + missing = [category for category in BASELINE_CATEGORIES if category["slug"] not in existing_slugs] + if missing: + op.bulk_insert(care_categories, missing) + + +def upgrade() -> None: + uuid_type = _uuid_type() + + if not _table_exists("care_categories"): + op.create_table( + "care_categories", + sa.Column("id", uuid_type, primary_key=True), + sa.Column("slug", sa.String(length=100), nullable=False, unique=True), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("icon_key", sa.String(length=100), nullable=True), + sa.Column("hero_image_url", sa.String(length=1024), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + if not _index_exists("care_categories", "ix_care_categories_slug"): + op.create_index("ix_care_categories_slug", "care_categories", ["slug"], unique=True) + if not _index_exists("care_categories", "ix_care_categories_active_sort"): + op.create_index("ix_care_categories_active_sort", "care_categories", ["is_active", "sort_order"]) + + _seed_categories() + + if not _column_exists("services", "category_id"): + op.add_column("services", sa.Column("category_id", uuid_type, nullable=True)) + if not _fk_exists("services", "fk_services_category_id_care_categories"): + op.create_foreign_key( + "fk_services_category_id_care_categories", + "services", + "care_categories", + ["category_id"], + ["id"], + ) + if not _index_exists("services", "ix_services_category_active"): + op.create_index("ix_services_category_active", "services", ["category_id", "is_active"]) + + op.execute( + """ + UPDATE services + SET category_id = ( + SELECT id + FROM care_categories + WHERE slug = 'shoes' + LIMIT 1 + ) + WHERE category_id IS NULL + """ + ) + + if not _table_exists("company_care_categories"): + op.create_table( + "company_care_categories", + sa.Column("company_id", uuid_type, sa.ForeignKey("companies.id"), primary_key=True), + sa.Column("category_id", uuid_type, sa.ForeignKey("care_categories.id"), primary_key=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("company_id", "category_id"), + ) + if not _index_exists("company_care_categories", "ix_company_care_categories_category"): + op.create_index( + "ix_company_care_categories_category", + "company_care_categories", + ["category_id", "is_active"], + ) + + if not _table_exists("provider_care_categories"): + op.create_table( + "provider_care_categories", + sa.Column("provider_id", uuid_type, sa.ForeignKey("users.id"), primary_key=True), + sa.Column("category_id", uuid_type, sa.ForeignKey("care_categories.id"), primary_key=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("provider_id", "category_id"), + ) + if not _index_exists("provider_care_categories", "ix_provider_care_categories_category"): + op.create_index( + "ix_provider_care_categories_category", + "provider_care_categories", + ["category_id", "is_active"], + ) + + op.execute( + """ + INSERT INTO company_care_categories (company_id, category_id, is_active, created_at) + SELECT DISTINCT company_id, category_id, TRUE, NOW() + FROM services + WHERE category_id IS NOT NULL + AND company_id IS NOT NULL + ON CONFLICT (company_id, category_id) DO NOTHING + """ + ) + + +def downgrade() -> None: + bind = op.get_bind() + + if _table_exists("provider_care_categories"): + op.drop_table("provider_care_categories") + if _table_exists("company_care_categories"): + op.drop_table("company_care_categories") + + if _index_exists("services", "ix_services_category_active"): + op.drop_index("ix_services_category_active", table_name="services") + if _fk_exists("services", "fk_services_category_id_care_categories"): + op.drop_constraint("fk_services_category_id_care_categories", "services", type_="foreignkey") + if _column_exists("services", "category_id"): + op.drop_column("services", "category_id") + + if _table_exists("care_categories"): + if bind.dialect.name != "sqlite": + if _index_exists("care_categories", "ix_care_categories_active_sort"): + op.drop_index("ix_care_categories_active_sort", table_name="care_categories") + if _index_exists("care_categories", "ix_care_categories_slug"): + op.drop_index("ix_care_categories_slug", table_name="care_categories") + op.drop_table("care_categories") diff --git a/apps/api/app/core/brand.py b/apps/api/app/core/brand.py new file mode 100644 index 0000000..8660245 --- /dev/null +++ b/apps/api/app/core/brand.py @@ -0,0 +1,2 @@ +APP_NAME = "ShoeInn" +CARE_MARKETPLACE_POSITIONING = "Premium care, pickup, and delivery from trusted local teams." diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 9d09799..dd21af5 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -10,6 +10,7 @@ from app.routers import ( appointments, auth, + care_categories, companies, company_ops, live, @@ -52,6 +53,7 @@ app.include_router(health.router) app.include_router(auth.router) app.include_router(admin.router) +app.include_router(care_categories.router) app.include_router(companies.router) app.include_router(services.router) app.include_router(appointments.router) diff --git a/apps/api/app/models/__init__.py b/apps/api/app/models/__init__.py index b173682..f35eeaf 100644 --- a/apps/api/app/models/__init__.py +++ b/apps/api/app/models/__init__.py @@ -6,13 +6,16 @@ from .appointment_hold import AppointmentHold, HoldStatus from .appointment_location_update import AppointmentLocationUpdate from .available_slot import AvailableSlot +from .care_category import BASELINE_CARE_CATEGORIES, CareCategory from .company import Company +from .company_care_category import CompanyCareCategory from .company_user import CompanyUser from .notification import Notification from .push_token import PushToken from .refresh_token import RefreshToken from .notification_outbox import NotificationOutbox from .notification_event import NotificationEvent +from .provider_care_category import ProviderCareCategory from .service import Service from .user import User @@ -24,7 +27,10 @@ "AppointmentLocationUpdate", "AppointmentStatus", "AvailableSlot", + "BASELINE_CARE_CATEGORIES", + "CareCategory", "Company", + "CompanyCareCategory", "CompanyUser", "Notification", "PushToken", @@ -33,6 +39,7 @@ "NotificationOutbox", "NotificationEvent", "PaymentStatus", + "ProviderCareCategory", "Service", "User", ] diff --git a/apps/api/app/models/appointment.py b/apps/api/app/models/appointment.py index 3097c64..2a7f3ec 100644 --- a/apps/api/app/models/appointment.py +++ b/apps/api/app/models/appointment.py @@ -9,6 +9,7 @@ from app.enums import AppointmentStatus from sqlalchemy import Column, DateTime, Enum, ForeignKey, Integer, String from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship from app.core.db import Base @@ -67,3 +68,25 @@ class Appointment(Base): default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), ) + + service = relationship("Service") + + @property + def service_name(self) -> str | None: + return self.service.name if self.service is not None else None + + @property + def category_id(self): + return self.service.category_id if self.service is not None else None + + @property + def category_slug(self) -> str | None: + return self.service.category_slug if self.service is not None else None + + @property + def category_name(self) -> str | None: + return self.service.category_name if self.service is not None else None + + @property + def category_icon_key(self) -> str | None: + return self.service.category_icon_key if self.service is not None else None diff --git a/apps/api/app/models/care_category.py b/apps/api/app/models/care_category.py new file mode 100644 index 0000000..4263686 --- /dev/null +++ b/apps/api/app/models/care_category.py @@ -0,0 +1,92 @@ +"""Care category models and baseline marketplace categories.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import uuid + +from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, Index +from sqlalchemy.dialects.postgresql import UUID + +from app.core.db import Base + + +# Baseline categories are shared by migrations and seed helpers so fresh, +# test, and upgraded databases agree on stable marketplace slugs. +BASELINE_CARE_CATEGORIES: tuple[dict[str, object], ...] = ( + { + "slug": "shoes", + "name": "Shoes", + "description": "Premium cleaning, restoration, and pickup care for sneakers and footwear.", + "sort_order": 10, + "icon_key": "footprints", + }, + { + "slug": "laundry", + "name": "Laundry", + "description": "Wash, fold, pickup, and delivery care for everyday garments and linens.", + "sort_order": 20, + "icon_key": "shirt", + }, + { + "slug": "dry-cleaning", + "name": "Dry Cleaning", + "description": "Professional care for delicate garments, suits, dresses, and formal wear.", + "sort_order": 30, + "icon_key": "sparkles", + }, + { + "slug": "handbags-leather", + "name": "Handbags & Leather", + "description": "Specialist cleaning and conditioning for handbags, leather goods, and accessories.", + "sort_order": 40, + "icon_key": "briefcase", + }, + { + "slug": "rugs-textiles", + "name": "Rugs & Textiles", + "description": "Premium care for rugs, home textiles, and delicate fabric items.", + "sort_order": 50, + "icon_key": "layout-grid", + }, + { + "slug": "alterations", + "name": "Alterations", + "description": "Tailoring, fit adjustments, repairs, and garment finishing services.", + "sort_order": 60, + "icon_key": "scissors", + }, +) + + +class CareCategory(Base): + """Represents a premium care marketplace category.""" + + __tablename__ = "care_categories" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + slug = Column(String(100), nullable=False, unique=True) + name = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + sort_order = Column(Integer, nullable=False, default=0) + icon_key = Column(String(100), nullable=True) + hero_image_url = Column(String(1024), nullable=True) + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + updated_at = Column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + __table_args__ = ( + Index("ix_care_categories_slug", "slug", unique=True), + Index("ix_care_categories_active_sort", "is_active", "sort_order"), + ) + + @property + def display_order(self) -> int: + """Expose API-facing display order while keeping the DB column stable.""" + + return int(self.sort_order or 0) diff --git a/apps/api/app/models/company_care_category.py b/apps/api/app/models/company_care_category.py new file mode 100644 index 0000000..235d7be --- /dev/null +++ b/apps/api/app/models/company_care_category.py @@ -0,0 +1,26 @@ +"""Optional company-to-care-category capability metadata.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID + +from app.core.db import Base + + +class CompanyCareCategory(Base): + """Represents categories a company can offer without changing service behavior.""" + + __tablename__ = "company_care_categories" + + company_id = Column(UUID(as_uuid=True), ForeignKey("companies.id"), primary_key=True) + category_id = Column(UUID(as_uuid=True), ForeignKey("care_categories.id"), primary_key=True) + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + + __table_args__ = ( + UniqueConstraint("company_id", "category_id"), + Index("ix_company_care_categories_category", "category_id", "is_active"), + ) diff --git a/apps/api/app/models/provider_care_category.py b/apps/api/app/models/provider_care_category.py new file mode 100644 index 0000000..3510161 --- /dev/null +++ b/apps/api/app/models/provider_care_category.py @@ -0,0 +1,26 @@ +"""Optional provider-to-care-category capability metadata.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID + +from app.core.db import Base + + +class ProviderCareCategory(Base): + """Represents categories a provider can handle without enforcing dispatch rules.""" + + __tablename__ = "provider_care_categories" + + provider_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), primary_key=True) + category_id = Column(UUID(as_uuid=True), ForeignKey("care_categories.id"), primary_key=True) + is_active = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)) + + __table_args__ = ( + UniqueConstraint("provider_id", "category_id"), + Index("ix_provider_care_categories_category", "category_id", "is_active"), + ) diff --git a/apps/api/app/models/service.py b/apps/api/app/models/service.py index 98a032f..ff6e70a 100644 --- a/apps/api/app/models/service.py +++ b/apps/api/app/models/service.py @@ -7,17 +7,19 @@ from sqlalchemy import Boolean, Column, DateTime, Integer, String, Text, Index, ForeignKey from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship from app.core.db import Base class Service(Base): - """Represents a bookable sneaker service.""" + """Represents a bookable care service.""" __tablename__ = "services" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) company_id = Column(UUID(as_uuid=True), ForeignKey("companies.id"), nullable=False) + category_id = Column(UUID(as_uuid=True), ForeignKey("care_categories.id"), nullable=True) name = Column(String(255), nullable=False) slug = Column(String(255), nullable=False, unique=True) description = Column(Text, nullable=True) @@ -36,4 +38,25 @@ class Service(Base): Index("ix_services_slug", "slug", unique=True), Index("ix_services_active_name", "is_active", "name"), Index("ix_services_company", "company_id"), + Index("ix_services_category_active", "category_id", "is_active"), ) + + category = relationship("CareCategory") + + @property + def category_slug(self) -> str | None: + """Expose optional category slug in service read models.""" + + return self.category.slug if self.category is not None else None + + @property + def category_name(self) -> str | None: + """Expose optional category name in service read models.""" + + return self.category.name if self.category is not None else None + + @property + def category_icon_key(self) -> str | None: + """Expose optional category display icon in service read models.""" + + return self.category.icon_key if self.category is not None else None diff --git a/apps/api/app/routers/__init__.py b/apps/api/app/routers/__init__.py index 53f1501..661d396 100644 --- a/apps/api/app/routers/__init__.py +++ b/apps/api/app/routers/__init__.py @@ -1,9 +1,10 @@ -from . import admin, appointments, auth, companies, company_ops, dev_seed, health, live, payment_return, push, services, slots, users, webhooks +from . import admin, appointments, auth, care_categories, companies, company_ops, dev_seed, health, live, payment_return, push, services, slots, users, webhooks __all__ = [ "admin", "appointments", "auth", + "care_categories", "companies", "company_ops", "dev_seed", diff --git a/apps/api/app/routers/appointments.py b/apps/api/app/routers/appointments.py index 5b0b839..4ce577a 100644 --- a/apps/api/app/routers/appointments.py +++ b/apps/api/app/routers/appointments.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, File, Header, HTTPException, Request, UploadFile, status from sqlalchemy import func, or_ -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from app.core.config import settings from app.core.db import get_db @@ -126,6 +126,11 @@ def _serialize_appointment(appointment: Appointment) -> AppointmentRead: "id": appointment.id, "company_id": appointment.company_id, "service_id": appointment.service_id, + "service_name": appointment.service_name, + "category_id": appointment.category_id, + "category_slug": appointment.category_slug, + "category_name": appointment.category_name, + "category_icon_key": appointment.category_icon_key, "hold_id": appointment.hold_id, "type": appointment.type, "customer_name": appointment.customer_name, @@ -188,6 +193,10 @@ def _serialize_quote(quote: BookingQuote) -> AppointmentQuoteRead: return AppointmentQuoteRead( service_id=UUID(quote.service_id), service_name=quote.service_name, + category_id=UUID(quote.category_id) if quote.category_id else None, + category_slug=quote.category_slug, + category_name=quote.category_name, + category_icon_key=quote.category_icon_key, currency=quote.currency, line_items=[ { @@ -267,21 +276,23 @@ def list_my_appointments( q = ( db.query(Appointment) + .options(joinedload(Appointment.service).joinedload(Service.category)) .filter(or_(*owner_filters)) .filter(Appointment.status != AppointmentStatus.cancelled) .order_by(Appointment.start_time.desc()) ) for appt in q.all(): - service_name = None - if appt.service_id: - svc = db.get(Service, appt.service_id) - service_name = svc.name if svc else None items.append( AppointmentListItem.model_validate( { "id": appt.id, "company_id": appt.company_id, - "service_name": service_name, + "service_id": appt.service_id, + "service_name": appt.service_name, + "category_id": appt.category_id, + "category_slug": appt.category_slug, + "category_name": appt.category_name, + "category_icon_key": appt.category_icon_key, "customer_name": appt.customer_name, "customer_phone": appt.customer_phone, "address_line1": appt.address_line1, @@ -459,7 +470,12 @@ def read_appointment( current_user=Depends(get_current_user), db: Session = Depends(get_db), ) -> AppointmentRead: - appointment = db.get(Appointment, appointment_id) + appointment = ( + db.query(Appointment) + .options(joinedload(Appointment.service).joinedload(Service.category)) + .filter(Appointment.id == appointment_id) + .one_or_none() + ) if appointment is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Appointment not found") @@ -489,7 +505,12 @@ def read_provider_location( current_customer=Depends(get_current_customer), db: Session = Depends(get_db), ) -> AppointmentProviderLocationResponse: - appointment = db.get(Appointment, appointment_id) + appointment = ( + db.query(Appointment) + .options(joinedload(Appointment.service).joinedload(Service.category)) + .filter(Appointment.id == appointment_id) + .one_or_none() + ) if appointment is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Appointment not found") @@ -520,7 +541,12 @@ def read_assignment_company( ) -> AppointmentAssignmentRead: current_user, company_id = current - appointment = db.get(Appointment, appointment_id) + appointment = ( + db.query(Appointment) + .options(joinedload(Appointment.service).joinedload(Service.category)) + .filter(Appointment.id == appointment_id) + .one_or_none() + ) if appointment is None: raise HTTPException(status_code=404, detail="Appointment not found") @@ -546,6 +572,12 @@ def read_assignment_company( "appointment_id": assignment.appointment_id, "company_id": company_id, "user_id": assignment.user_id, + "service_id": appointment.service_id, + "service_name": appointment.service_name, + "category_id": appointment.category_id, + "category_slug": appointment.category_slug, + "category_name": appointment.category_name, + "category_icon_key": appointment.category_icon_key, "assigned_at": assignment.assigned_at, "unassigned_at": assignment.unassigned_at, "is_active": assignment.is_active, @@ -564,7 +596,12 @@ def read_assignment( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db), ) -> AppointmentAssignmentRead: - appointment = db.get(Appointment, appointment_id) + appointment = ( + db.query(Appointment) + .options(joinedload(Appointment.service).joinedload(Service.category)) + .filter(Appointment.id == appointment_id) + .one_or_none() + ) if appointment is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Appointment not found") @@ -591,6 +628,12 @@ def read_assignment( "id": assignment.id, "appointment_id": assignment.appointment_id, "user_id": assignment.user_id, + "service_id": appointment.service_id, + "service_name": appointment.service_name, + "category_id": appointment.category_id, + "category_slug": appointment.category_slug, + "category_name": appointment.category_name, + "category_icon_key": appointment.category_icon_key, "assigned_at": assignment.assigned_at, "unassigned_at": assignment.unassigned_at, "is_active": assignment.is_active, @@ -823,7 +866,12 @@ def refresh_appointment_payment( db: Session = Depends(get_db), gateway: PaymentGateway = Depends(get_payment_gateway), ) -> AppointmentRead: - appointment = db.get(Appointment, appointment_id) + appointment = ( + db.query(Appointment) + .options(joinedload(Appointment.service).joinedload(Service.category)) + .filter(Appointment.id == appointment_id) + .one_or_none() + ) if appointment is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Appointment not found") @@ -849,7 +897,12 @@ def cancel_appointment_payment( current_customer=Depends(get_current_customer), db: Session = Depends(get_db), ) -> AppointmentRead: - appointment = db.get(Appointment, appointment_id) + appointment = ( + db.query(Appointment) + .options(joinedload(Appointment.service).joinedload(Service.category)) + .filter(Appointment.id == appointment_id) + .one_or_none() + ) if appointment is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Appointment not found") diff --git a/apps/api/app/routers/care_categories.py b/apps/api/app/routers/care_categories.py new file mode 100644 index 0000000..a51a954 --- /dev/null +++ b/apps/api/app/routers/care_categories.py @@ -0,0 +1,24 @@ +"""Read-only care category marketplace endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.core.db import get_db +from app.models import CareCategory +from app.schemas.care_category import CareCategoryRead + +router = APIRouter(prefix="/care-categories", tags=["care-categories"]) + + +@router.get("", response_model=list[CareCategoryRead]) +def list_care_categories(db: Session = Depends(get_db)) -> list[CareCategory]: + """Return active care categories ordered for marketplace display.""" + + return ( + db.query(CareCategory) + .filter(CareCategory.is_active.is_(True)) + .order_by(CareCategory.sort_order.asc(), CareCategory.name.asc()) + .all() + ) diff --git a/apps/api/app/routers/companies.py b/apps/api/app/routers/companies.py index 03b0132..bda0fd5 100644 --- a/apps/api/app/routers/companies.py +++ b/apps/api/app/routers/companies.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from app.core.db import get_db +from app.models import CareCategory from app.models.company import Company from app.models.service import Service from app.schemas.company import CompanyOut @@ -31,11 +32,44 @@ def normalize_city(city: str) -> str: return s +def _attach_offered_categories(db: Session, companies: list[Company]) -> list[Company]: + """Attach active category metadata derived from each company's active services.""" + + company_ids = [company.id for company in companies] + if not company_ids: + return companies + + rows = ( + db.query(Service.company_id, CareCategory) + .join(CareCategory, Service.category_id == CareCategory.id) + .filter( + Service.company_id.in_(company_ids), + Service.is_active.is_(True), + CareCategory.is_active.is_(True), + ) + .order_by(CareCategory.sort_order.asc(), CareCategory.name.asc()) + .all() + ) + categories_by_company: dict[UUID, list[CareCategory]] = {company.id: [] for company in companies} + seen: dict[UUID, set[UUID]] = {company.id: set() for company in companies} + for company_id, category in rows: + if category.id in seen[company_id]: + continue + categories_by_company[company_id].append(category) + seen[company_id].add(category.id) + + for company in companies: + company.offered_categories = categories_by_company.get(company.id, []) + return companies + + @router.get("", response_model=list[CompanyOut]) def list_companies( query: str = "", city: str | None = None, state: str | None = None, + category_slug: str | None = None, + category_id: UUID | None = None, db: Session = Depends(get_db), ): q = db.query(Company).filter(Company.is_active.is_(True)) @@ -62,7 +96,16 @@ def list_companies( if state: q = q.filter(func.upper(func.trim(Company.state)) == state.strip().upper()) - return q.all() + if category_id or category_slug: + q = q.join(Service, Service.company_id == Company.id).join(CareCategory, Service.category_id == CareCategory.id) + q = q.filter(Service.is_active.is_(True), CareCategory.is_active.is_(True)) + if category_id: + q = q.filter(Service.category_id == category_id) + if category_slug: + q = q.filter(CareCategory.slug == category_slug) + q = q.distinct() + + return _attach_offered_categories(db, q.all()) @@ -71,13 +114,26 @@ def get_company(company_id: UUID, db: Session = Depends(get_db)): company = db.get(Company, company_id) if not company or not company.is_active: raise HTTPException(status_code=404, detail="Not found") + _attach_offered_categories(db, [company]) return company @router.get("/{company_id}/services", response_model=list[ServiceOut]) -def company_services(company_id: UUID, db: Session = Depends(get_db)): +def company_services( + company_id: UUID, + category_slug: str | None = None, + category_id: UUID | None = None, + db: Session = Depends(get_db), +): company = db.get(Company, company_id) if not company or not company.is_active: raise HTTPException(status_code=404, detail="Not found") q = db.query(Service).filter_by(company_id=company_id).filter(Service.is_active.is_(True)) + if category_id: + q = q.filter(Service.category_id == category_id) + if category_slug: + q = q.join(Service.category).filter( + CareCategory.slug == category_slug, + CareCategory.is_active.is_(True), + ) return q.all() diff --git a/apps/api/app/routers/company_ops.py b/apps/api/app/routers/company_ops.py index 29fdb63..1c598eb 100644 --- a/apps/api/app/routers/company_ops.py +++ b/apps/api/app/routers/company_ops.py @@ -26,7 +26,6 @@ AppointmentEvent, AppointmentLocationUpdate, Notification, - Service, ) from app.models.company_user import CompanyUser from app.models.user import User @@ -115,12 +114,29 @@ def _provider_display_name(user: User | None) -> str | None: return display_name or None -def _serialize_assignment(assignment: AppointmentAssignment, provider: User | None) -> AppointmentAssignmentRead: +def _appointment_service_context(appt: Appointment) -> dict[str, object | None]: + return { + "service_id": appt.service_id, + "service_name": appt.service_name, + "category_id": appt.category_id, + "category_slug": appt.category_slug, + "category_name": appt.category_name, + "category_icon_key": appt.category_icon_key, + } + + +def _serialize_assignment( + assignment: AppointmentAssignment, + provider: User | None, + appointment: Appointment | None = None, +) -> AppointmentAssignmentRead: + service_context = _appointment_service_context(appointment) if appointment else {} return AppointmentAssignmentRead.model_validate( { "id": assignment.id, "appointment_id": assignment.appointment_id, "user_id": assignment.user_id, + **service_context, "assigned_at": assignment.assigned_at, "unassigned_at": assignment.unassigned_at, "is_active": assignment.is_active, @@ -287,7 +303,6 @@ def open_appointments(current=Depends(get_current_company_user), db: Session = D items = [] for appt, assignment, assigned_user in rows: - svc = db.get(Service, appt.service_id) if appt.service_id else None items.append( { "id": appt.id, @@ -299,7 +314,7 @@ def open_appointments(current=Depends(get_current_company_user), db: Session = D "city": appt.city, "state": appt.state, "postal_code": appt.postal_code, - "service_name": svc.name if svc else None, + **_appointment_service_context(appt), "start_time": appt.start_time, "status": appt.status.value, # assignment info @@ -338,7 +353,6 @@ def open_appointments(current=Depends(get_current_company_user), db: Session = D items = [] for appt in q.all(): - svc = db.get(Service, appt.service_id) if appt.service_id else None items.append( { "id": appt.id, @@ -350,7 +364,7 @@ def open_appointments(current=Depends(get_current_company_user), db: Session = D "city": appt.city, "state": appt.state, "postal_code": appt.postal_code, - "service_name": svc.name if svc else None, + **_appointment_service_context(appt), "start_time": appt.start_time, "status": appt.status.value, "is_assigned": False, @@ -386,7 +400,6 @@ def my_appointments(current=Depends(get_current_company_user), db: Session = Dep items = [] for appt, assignment in rows: - svc = db.get(Service, appt.service_id) if appt.service_id else None items.append( { "id": appt.id, @@ -398,7 +411,7 @@ def my_appointments(current=Depends(get_current_company_user), db: Session = Dep "city": appt.city, "state": appt.state, "postal_code": appt.postal_code, - "service_name": svc.name if svc else None, + **_appointment_service_context(appt), "start_time": appt.start_time, "status": appt.status.value, # assignment info @@ -426,10 +439,6 @@ def claimed_appointments(current=Depends(get_current_company_user), db: Session ) items = [] for appt, assignment in q.all(): - service_name = None - if appt.service_id: - svc = db.get(Service, appt.service_id) - service_name = svc.name if svc else None items.append( { "id": appt.id, @@ -441,7 +450,7 @@ def claimed_appointments(current=Depends(get_current_company_user), db: Session "city": appt.city, "state": appt.state, "postal_code": appt.postal_code, - "service_name": service_name, + **_appointment_service_context(appt), "start_time": appt.start_time, "status": appt.status.value, "is_assigned": True, @@ -506,7 +515,7 @@ def claim_appointment( old_provider=None, new_provider=current_user, ) - return _serialize_assignment(assignment, current_user) + return _serialize_assignment(assignment, current_user, appt) @router.post( @@ -575,7 +584,7 @@ def assign_appointment( old_provider=None, new_provider=provider, ) - return _serialize_assignment(assignment, provider) + return _serialize_assignment(assignment, provider, appt) @router.post( @@ -669,7 +678,7 @@ def reassign_appointment( old_provider=old_provider, new_provider=new_provider, ) - return _serialize_assignment(assignment, new_provider) + return _serialize_assignment(assignment, new_provider, appt) @router.get("/appointments") @@ -678,14 +687,10 @@ def company_appointments(current=Depends(get_current_company_user), db: Session q = db.query(Appointment).filter_by(company_id=company_id).order_by(Appointment.start_time.desc()) items = [] for appt in q.all(): - service_name = None - if appt.service_id: - svc = db.get(Service, appt.service_id) - service_name = svc.name if svc else None items.append( { "id": appt.id, - "service_name": service_name, + **_appointment_service_context(appt), "type": appt.type, "start_time": appt.start_time, "status": appt.status.value, @@ -810,14 +815,10 @@ def all_appointments(current=Depends(get_current_company_admin), db: Session = D results = [] for appt, assignment, user in q.all(): - service_name = None - if appt.service_id: - svc = db.get(Service, appt.service_id) - service_name = svc.name if svc else None results.append( { "id": appt.id, - "service_name": service_name, + **_appointment_service_context(appt), "type": appt.type, "start_time": appt.start_time, "status": appt.status.value, diff --git a/apps/api/app/routers/dev_seed.py b/apps/api/app/routers/dev_seed.py index 2f649af..af91321 100644 --- a/apps/api/app/routers/dev_seed.py +++ b/apps/api/app/routers/dev_seed.py @@ -14,15 +14,19 @@ from app.models.appointment_hold import AppointmentHold from app.models.appointment_location_update import AppointmentLocationUpdate from app.models.available_slot import AvailableSlot +from app.models.care_category import CareCategory from app.models.company import Company +from app.models.company_care_category import CompanyCareCategory from app.models.company_user import CompanyUser from app.models.notification import Notification from app.models.notification_event import NotificationEvent from app.models.notification_outbox import NotificationOutbox +from app.models.provider_care_category import ProviderCareCategory from app.models.push_token import PushToken from app.models.refresh_token import RefreshToken from app.models.service import Service from app.models.user import User +from scripts.seed_services import ensure_baseline_care_categories router = APIRouter(prefix="/dev", tags=["dev"]) @@ -44,6 +48,7 @@ class SeedUser(TypedDict): class SeedService(TypedDict): name: str slug: str + category_slug: str price_cents: int duration_minutes: int @@ -163,17 +168,26 @@ class SeedMarket(TypedDict): ], "services": [ { - "name": "Pickup & Press Refresh", + "name": "Signature Sneaker Deep Clean", "slug": "pelham-pickup-refresh", - "price_cents": 2200, - "duration_minutes": 45, + "category_slug": "shoes", + "price_cents": 3600, + "duration_minutes": 60, }, { - "name": "Delivery Ready Deep Clean", + "name": "Wash & Fold Essentials", "slug": "pelham-deep-clean", - "price_cents": 3600, + "category_slug": "laundry", + "price_cents": 2800, "duration_minutes": 75, }, + { + "name": "Executive Dry Cleaning", + "slug": "pelham-executive-dry-cleaning", + "category_slug": "dry-cleaning", + "price_cents": 4200, + "duration_minutes": 90, + }, ], }, { @@ -192,17 +206,26 @@ class SeedMarket(TypedDict): ], "services": [ { - "name": "Helena Everyday Clean", + "name": "Helena Everyday Sneaker Care", "slug": "helena-everyday-clean", + "category_slug": "shoes", "price_cents": 2400, "duration_minutes": 45, }, { - "name": "Helena Premium Restore", + "name": "Designer Handbag Refresh", "slug": "helena-premium-restore", - "price_cents": 4200, + "category_slug": "handbags-leather", + "price_cents": 5200, "duration_minutes": 90, }, + { + "name": "Hemming & Minor Alterations", + "slug": "helena-hemming-minor-alterations", + "category_slug": "alterations", + "price_cents": 3000, + "duration_minutes": 60, + }, ], }, { @@ -221,17 +244,26 @@ class SeedMarket(TypedDict): ], "services": [ { - "name": "Fast Turnaround Refresh", + "name": "Area Rug Refresh", "slug": "alabaster-fast-refresh", - "price_cents": 2100, - "duration_minutes": 40, + "category_slug": "rugs-textiles", + "price_cents": 6800, + "duration_minutes": 120, }, { "name": "White Pair Recovery", "slug": "alabaster-white-recovery", + "category_slug": "shoes", "price_cents": 3800, "duration_minutes": 70, }, + { + "name": "Leather Conditioning & Care", + "slug": "alabaster-leather-conditioning-care", + "category_slug": "handbags-leather", + "price_cents": 5600, + "duration_minutes": 95, + }, ], }, ], @@ -354,17 +386,26 @@ class SeedMarket(TypedDict): ], "services": [ { - "name": "Pickup & Press Refresh", + "name": "Signature Sneaker Deep Clean", "slug": "mtjuliet-pickup-refresh", - "price_cents": 2200, - "duration_minutes": 45, + "category_slug": "shoes", + "price_cents": 3600, + "duration_minutes": 60, }, { - "name": "Delivery Ready Deep Clean", + "name": "Wash & Fold Essentials", "slug": "mtjuliet-deep-clean", - "price_cents": 3600, + "category_slug": "laundry", + "price_cents": 2800, "duration_minutes": 75, }, + { + "name": "Executive Dry Cleaning", + "slug": "mtjuliet-executive-dry-cleaning", + "category_slug": "dry-cleaning", + "price_cents": 4200, + "duration_minutes": 90, + }, ], }, { @@ -383,17 +424,26 @@ class SeedMarket(TypedDict): ], "services": [ { - "name": "Providence Everyday Clean", + "name": "Providence Everyday Sneaker Care", "slug": "providence-everyday-clean", + "category_slug": "shoes", "price_cents": 2400, "duration_minutes": 45, }, { - "name": "Providence Premium Restore", + "name": "Designer Handbag Refresh", "slug": "providence-premium-restore", - "price_cents": 4200, + "category_slug": "handbags-leather", + "price_cents": 5200, "duration_minutes": 90, }, + { + "name": "Hemming & Minor Alterations", + "slug": "providence-hemming-minor-alterations", + "category_slug": "alterations", + "price_cents": 3000, + "duration_minutes": 60, + }, ], }, { @@ -412,17 +462,26 @@ class SeedMarket(TypedDict): ], "services": [ { - "name": "Fast Turnaround Refresh", + "name": "Area Rug Refresh", "slug": "goldenbear-fast-refresh", - "price_cents": 2100, - "duration_minutes": 40, + "category_slug": "rugs-textiles", + "price_cents": 6800, + "duration_minutes": 120, }, { "name": "White Pair Recovery", "slug": "goldenbear-white-recovery", + "category_slug": "shoes", "price_cents": 3800, "duration_minutes": 70, }, + { + "name": "Leather Conditioning & Care", + "slug": "goldenbear-leather-conditioning-care", + "category_slug": "handbags-leather", + "price_cents": 5600, + "duration_minutes": 95, + }, ], }, ], @@ -471,10 +530,13 @@ def seed( raise HTTPException(status_code=400, detail="Unsupported demo_market") market = DEMO_MARKETS[demo_market] + categories_by_slug = ensure_baseline_care_categories(db) main_demo_customer_home = market["customer_job_addresses"][0] created = { "companies": 0, "services": 0, + "company_categories": 0, + "provider_categories": 0, "slots": 0, "users": 0, "company_users": 0, @@ -491,6 +553,9 @@ def seed( demo_company_ids = [company.id for company in demo_companies] if demo_company_ids: + db.query(CompanyCareCategory).filter(CompanyCareCategory.company_id.in_(demo_company_ids)).delete( + synchronize_session=False + ) demo_appointment_ids = select(Appointment.id).where(Appointment.company_id.in_(demo_company_ids)) db.query(NotificationEvent).filter( NotificationEvent.notification_id.in_( @@ -516,6 +581,9 @@ def seed( db.query(Company).filter(Company.id.in_(demo_company_ids)).delete(synchronize_session=False) if demo_user_ids: + db.query(ProviderCareCategory).filter(ProviderCareCategory.provider_id.in_(demo_user_ids)).delete( + synchronize_session=False + ) db.query(PushToken).filter(PushToken.user_id.in_(demo_user_ids)).delete(synchronize_session=False) db.query(RefreshToken).filter(RefreshToken.user_id.in_(demo_user_ids)).delete(synchronize_session=False) db.query(User).filter(User.id.in_(demo_user_ids)).delete(synchronize_session=False) @@ -550,10 +618,15 @@ def get_or_create_company(name: str) -> Company: for company_config in market["companies"] } - def get_or_create_service(company_id, name, slug, price_cents, duration_minutes) -> Service: + def get_or_create_service(company_id, name, slug, category_slug, price_cents, duration_minutes) -> Service: + category = categories_by_slug.get(category_slug) + if category is None: + raise HTTPException(status_code=500, detail=f"Unknown care category in seed data: {category_slug}") + service = db.query(Service).filter(Service.slug == slug).first() if service: service.company_id = company_id + service.category_id = category.id service.name = name service.price_cents = price_cents service.duration_minutes = duration_minutes @@ -561,6 +634,7 @@ def get_or_create_service(company_id, name, slug, price_cents, duration_minutes) service = Service( company_id=company_id, + category_id=category.id, name=name, slug=slug, price_cents=price_cents, @@ -580,6 +654,7 @@ def get_or_create_service(company_id, name, slug, price_cents, duration_minutes) company.id, service_config["name"], service_config["slug"], + service_config["category_slug"], service_config["price_cents"], service_config["duration_minutes"], ) @@ -615,9 +690,9 @@ def get_or_create_slot(company_id, service_id, start_time_utc) -> AvailableSlot: primary_company = seeded_companies[primary_company_config["name"]] secondary_company = seeded_companies[secondary_company_config["name"]] tertiary_company = seeded_companies[tertiary_company_config["name"]] - primary_service_one, primary_service_two = primary_company_config["services"] - secondary_service_one, secondary_service_two = secondary_company_config["services"] - tertiary_service_one, tertiary_service_two = tertiary_company_config["services"] + primary_service_one, primary_service_two, primary_service_three = primary_company_config["services"] + secondary_service_one, secondary_service_two, secondary_service_three = secondary_company_config["services"] + tertiary_service_one, tertiary_service_two, tertiary_service_three = tertiary_company_config["services"] get_or_create_slot( primary_company.id, @@ -634,6 +709,11 @@ def get_or_create_slot(company_id, service_id, start_time_utc) -> AvailableSlot: seeded_services[secondary_company.name][secondary_service_one["slug"]].id, slot_anchor + timedelta(hours=2), ) + get_or_create_slot( + secondary_company.id, + seeded_services[secondary_company.name][secondary_service_three["slug"]].id, + slot_anchor + timedelta(hours=5), + ) get_or_create_slot( tertiary_company.id, seeded_services[tertiary_company.name][tertiary_service_one["slug"]].id, @@ -720,13 +800,61 @@ def ensure_company_user(user_id, company_id): for provider in company_providers[company.name]: ensure_company_user(provider.id, company.id) + def ensure_company_category(company_id, category_id): + link = ( + db.query(CompanyCareCategory) + .filter(CompanyCareCategory.company_id == company_id, CompanyCareCategory.category_id == category_id) + .first() + ) + if link: + link.is_active = True + return + db.add(CompanyCareCategory(company_id=company_id, category_id=category_id, is_active=True)) + created["company_categories"] += 1 + + def ensure_provider_category(provider_id, category_id): + link = ( + db.query(ProviderCareCategory) + .filter(ProviderCareCategory.provider_id == provider_id, ProviderCareCategory.category_id == category_id) + .first() + ) + if link: + link.is_active = True + return + db.add(ProviderCareCategory(provider_id=provider_id, category_id=category_id, is_active=True)) + created["provider_categories"] += 1 + + for company_config in market["companies"]: + company = seeded_companies[company_config["name"]] + category_ids = { + categories_by_slug[service_config["category_slug"]].id + for service_config in company_config["services"] + } + for category_id in category_ids: + ensure_company_category(company.id, category_id) + ensure_provider_category(company_admins[company.name].id, category_id) + for provider in company_providers[company.name]: + ensure_provider_category(provider.id, category_id) + primary_quick_demo_company = seeded_companies[market["companies"][0]["name"]] quick_demo_provider = quick_demo_users.get("provider") if quick_demo_provider is not None: ensure_company_user(quick_demo_provider.id, primary_quick_demo_company.id) + primary_category_ids = { + categories_by_slug[service_config["category_slug"]].id + for service_config in market["companies"][0]["services"] + } + for category_id in primary_category_ids: + ensure_provider_category(quick_demo_provider.id, category_id) quick_demo_admin = quick_demo_users.get("company_admin") if quick_demo_admin is not None: ensure_company_user(quick_demo_admin.id, primary_quick_demo_company.id) + primary_category_ids = { + categories_by_slug[service_config["category_slug"]].id + for service_config in market["companies"][0]["services"] + } + for category_id in primary_category_ids: + ensure_provider_category(quick_demo_admin.id, category_id) appointment_sequence = {"value": 0} @@ -766,7 +894,7 @@ def create_demo_appointment(company: Company, service: Service, start_time: date ) primary_pickup = create_demo_appointment( primary_company, - seeded_services[primary_company.name][primary_service_one["slug"]], + seeded_services[primary_company.name][primary_service_two["slug"]], appointment_anchor + timedelta(minutes=20), AppointmentStatus.en_route_pickup, ) @@ -778,7 +906,7 @@ def create_demo_appointment(company: Company, service: Service, start_time: date ) primary_ready = create_demo_appointment( primary_company, - seeded_services[primary_company.name][primary_service_two["slug"]], + seeded_services[primary_company.name][primary_service_three["slug"]], appointment_anchor - timedelta(hours=1), AppointmentStatus.ready, ) @@ -792,7 +920,7 @@ def create_demo_appointment(company: Company, service: Service, start_time: date secondary_company, seeded_services[secondary_company.name][secondary_service_two["slug"]], appointment_anchor + timedelta(hours=4), - AppointmentStatus.confirmed, + AppointmentStatus.delivered, ) create_demo_appointment( tertiary_company, @@ -802,10 +930,16 @@ def create_demo_appointment(company: Company, service: Service, start_time: date ) create_demo_appointment( tertiary_company, - seeded_services[tertiary_company.name][tertiary_service_two["slug"]], + seeded_services[tertiary_company.name][tertiary_service_one["slug"]], appointment_anchor - timedelta(days=1, hours=2), AppointmentStatus.completed, ) + create_demo_appointment( + secondary_company, + seeded_services[secondary_company.name][secondary_service_three["slug"]], + appointment_anchor + timedelta(hours=6), + AppointmentStatus.confirmed, + ) primary_providers = company_providers[primary_company.name] db.add_all( diff --git a/apps/api/app/routers/payment_return.py b/apps/api/app/routers/payment_return.py index 7abea5c..2149971 100644 --- a/apps/api/app/routers/payment_return.py +++ b/apps/api/app/routers/payment_return.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Query from fastapi.responses import HTMLResponse +from app.core.brand import APP_NAME from app.core.config import settings @@ -34,7 +35,7 @@ def _build_app_return_url( def _payment_return_page(*, title: str, body: str, app_url: str | None) -> HTMLResponse: open_app_link = ( - f'

Open ShoeInn

' + f'

Open {APP_NAME}

' if app_url else "" ) @@ -50,9 +51,9 @@ def _payment_return_page(*, title: str, body: str, app_url: str | None) -> HTMLR else "" ) app_note = ( - "We also tried to reopen ShoeInn automatically. If the app did not open, use the button below." + f"We also tried to reopen {APP_NAME} automatically. If the app did not open, use the button below." if app_url - else "Return to ShoeInn manually and use the payment status action to continue your booking." + else f"Return to {APP_NAME} manually and use the payment status action to continue your care appointment." ) return HTMLResponse( f""" @@ -135,7 +136,7 @@ def payment_cancel_page( ) -> HTMLResponse: return _payment_return_page( title="Checkout canceled", - body="No payment was completed for this booking.", + body="No payment was completed for this care appointment.", app_url=_build_app_return_url( booking_id=booking_id, session_id=session_id, diff --git a/apps/api/app/routers/services.py b/apps/api/app/routers/services.py index 7ab97f8..6314804 100644 --- a/apps/api/app/routers/services.py +++ b/apps/api/app/routers/services.py @@ -6,10 +6,10 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from app.core.db import get_db -from app.models import Service +from app.models import CareCategory, Service from app.schemas.service import ServiceRead from app.services.availability import get_daily_availability @@ -17,12 +17,24 @@ @router.get("/services", response_model=list[ServiceRead]) -def list_services(company_id: UUID | None = None, db: Session = Depends(get_db)) -> list[ServiceRead]: +def list_services( + company_id: UUID | None = None, + category_slug: str | None = None, + category_id: UUID | None = None, + db: Session = Depends(get_db), +) -> list[ServiceRead]: """Return all active services ordered by name.""" - q = db.query(Service).filter(Service.is_active.is_(True)) + q = db.query(Service).options(joinedload(Service.category)).filter(Service.is_active.is_(True)) if company_id: q = q.filter(Service.company_id == company_id) + if category_id: + q = q.filter(Service.category_id == category_id) + if category_slug: + q = q.join(Service.category).filter( + CareCategory.slug == category_slug, + CareCategory.is_active.is_(True), + ) services = q.order_by(Service.name.asc()).all() return services diff --git a/apps/api/app/schemas/appointment.py b/apps/api/app/schemas/appointment.py index eeedc18..4ca07ba 100644 --- a/apps/api/app/schemas/appointment.py +++ b/apps/api/app/schemas/appointment.py @@ -93,6 +93,10 @@ class QuoteLineItem(BaseModel): class AppointmentQuoteRead(BaseModel): service_id: UUID service_name: str + category_id: UUID | None = None + category_slug: str | None = None + category_name: str | None = None + category_icon_key: str | None = None currency: str line_items: list[QuoteLineItem] subtotal: int @@ -107,6 +111,11 @@ class AppointmentRead(BaseModel): id: UUID company_id: UUID service_id: UUID + service_name: str | None = None + category_id: UUID | None = None + category_slug: str | None = None + category_name: str | None = None + category_icon_key: str | None = None hold_id: UUID | None = None type: str customer_name: str @@ -141,7 +150,12 @@ class AppointmentRead(BaseModel): class AppointmentListItem(BaseModel): id: UUID company_id: UUID | None = None + service_id: UUID | None = None service_name: str | None = None + category_id: UUID | None = None + category_slug: str | None = None + category_name: str | None = None + category_icon_key: str | None = None customer_name: str | None = None customer_phone: str | None = None address_line1: str | None = None @@ -173,6 +187,12 @@ class AppointmentAssignmentRead(BaseModel): id: UUID appointment_id: UUID user_id: UUID + service_id: UUID | None = None + service_name: str | None = None + category_id: UUID | None = None + category_slug: str | None = None + category_name: str | None = None + category_icon_key: str | None = None assigned_at: datetime unassigned_at: datetime | None = None is_active: bool diff --git a/apps/api/app/schemas/care_category.py b/apps/api/app/schemas/care_category.py new file mode 100644 index 0000000..c45a386 --- /dev/null +++ b/apps/api/app/schemas/care_category.py @@ -0,0 +1,32 @@ +"""Pydantic schemas for care categories.""" + +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class CareCategoryRead(BaseModel): + """Active care category representation for marketplace discovery.""" + + id: UUID + slug: str + name: str + description: str | None = None + icon_key: str | None = None + display_order: int + is_active: bool + + model_config = ConfigDict(from_attributes=True) + + +class CareCategorySummary(BaseModel): + """Compact category representation embedded in discovery responses.""" + + id: UUID + slug: str + name: str + icon_key: str | None = None + + model_config = ConfigDict(from_attributes=True) diff --git a/apps/api/app/schemas/company.py b/apps/api/app/schemas/company.py index 6489d65..3f0957e 100644 --- a/apps/api/app/schemas/company.py +++ b/apps/api/app/schemas/company.py @@ -1,6 +1,8 @@ from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, Field + +from app.schemas.care_category import CareCategorySummary class CompanyOut(BaseModel): @@ -14,6 +16,7 @@ class CompanyOut(BaseModel): city: str | None = None state: str | None = None postal_code: str | None = None + offered_categories: list[CareCategorySummary] = Field(default_factory=list) class Config: from_attributes = True diff --git a/apps/api/app/schemas/service.py b/apps/api/app/schemas/service.py index e85b823..2e6660a 100644 --- a/apps/api/app/schemas/service.py +++ b/apps/api/app/schemas/service.py @@ -13,6 +13,10 @@ class ServiceRead(BaseModel): id: UUID company_id: UUID | None = None + category_id: UUID | None = None + category_slug: str | None = None + category_name: str | None = None + category_icon_key: str | None = None name: str slug: str description: str | None = None @@ -28,6 +32,10 @@ class ServiceRead(BaseModel): class ServiceOut(BaseModel): id: UUID company_id: UUID | None = None + category_id: UUID | None = None + category_slug: str | None = None + category_name: str | None = None + category_icon_key: str | None = None name: str description: str | None = None duration_minutes: int diff --git a/apps/api/app/services/live_events.py b/apps/api/app/services/live_events.py index 4afa720..e70e12a 100644 --- a/apps/api/app/services/live_events.py +++ b/apps/api/app/services/live_events.py @@ -95,6 +95,12 @@ def _base_event( "event_kind": event_kind, "appointment_id": str(appointment.id), "company_id": str(appointment.company_id) if appointment.company_id else None, + "service_id": str(appointment.service_id) if appointment.service_id else None, + "service_name": appointment.service_name, + "category_id": str(appointment.category_id) if appointment.category_id else None, + "category_slug": appointment.category_slug, + "category_name": appointment.category_name, + "category_icon_key": appointment.category_icon_key, "occurred_at": datetime.now(timezone.utc).isoformat(), } diff --git a/apps/api/app/services/notifications.py b/apps/api/app/services/notifications.py index ca19220..8a1ab7f 100644 --- a/apps/api/app/services/notifications.py +++ b/apps/api/app/services/notifications.py @@ -25,6 +25,11 @@ def _default_payload(appointment: Appointment) -> dict: "appointment_id": str(appointment.id), "company_id": str(appointment.company_id) if appointment.company_id else None, "service_id": str(appointment.service_id) if appointment.service_id else None, + "service_name": appointment.service_name, + "category_id": str(appointment.category_id) if appointment.category_id else None, + "category_slug": appointment.category_slug, + "category_name": appointment.category_name, + "category_icon_key": appointment.category_icon_key, "destination_screen": "AppointmentDetail", "destination_appointment_id": str(appointment.id), "customer_name": appointment.customer_name, diff --git a/apps/api/app/services/pricing.py b/apps/api/app/services/pricing.py index 2eb5e13..92d3103 100644 --- a/apps/api/app/services/pricing.py +++ b/apps/api/app/services/pricing.py @@ -23,6 +23,10 @@ class PriceLineItem: class BookingQuote: service_id: str service_name: str + category_id: str | None + category_slug: str | None + category_name: str | None + category_icon_key: str | None currency: str line_items: list[PriceLineItem] subtotal: int @@ -62,6 +66,10 @@ def calculate_booking_quote(*, service: Service, booking_type: str, currency: st return BookingQuote( service_id=str(service.id), service_name=service.name, + category_id=str(service.category_id) if service.category_id else None, + category_slug=service.category_slug, + category_name=service.category_name, + category_icon_key=service.category_icon_key, currency=currency.lower(), line_items=line_items, subtotal=subtotal, diff --git a/apps/api/scripts/seed_services.py b/apps/api/scripts/seed_services.py index e88cb7f..fb87b79 100644 --- a/apps/api/scripts/seed_services.py +++ b/apps/api/scripts/seed_services.py @@ -7,52 +7,98 @@ from sqlalchemy.orm import Session from app.core.db import SessionLocal -from app.models import Company, Service +from app.models import BASELINE_CARE_CATEGORIES, CareCategory, Company, Service SEED_SERVICES: list[dict[str, object]] = [ { - "name": "Basic Clean", + "name": "Signature Sneaker Deep Clean", "slug": "basic-clean", + "category_slug": "shoes", "description": "Exterior wipe, lace wash, deodorize.", - "duration_minutes": 30, - "price_cents": 2000, + "duration_minutes": 60, + "price_cents": 3600, }, { - "name": "Deep Clean", + "name": "Wash & Fold Essentials", "slug": "deep-clean", - "description": "Deep outsole, midsole, insole, full deodorize.", - "duration_minutes": 60, - "price_cents": 3500, + "category_slug": "laundry", + "description": "Everyday wash, fold, pickup, and delivery care.", + "duration_minutes": 75, + "price_cents": 2800, }, { - "name": "Whitening & Brighten", + "name": "Executive Dry Cleaning", "slug": "whiten", - "description": "Oxidation treatment for midsoles.", - "duration_minutes": 45, - "price_cents": 3000, + "category_slug": "dry-cleaning", + "description": "Professional care for suits, dresses, and delicate garments.", + "duration_minutes": 90, + "price_cents": 4200, }, { - "name": "Premium Restore", + "name": "Designer Handbag Refresh", "slug": "premium-restore", - "description": "Deep clean + scuffs + crease care.", + "category_slug": "handbags-leather", + "description": "Specialist cleaning and conditioning for handbags and leather goods.", "duration_minutes": 90, - "price_cents": 6000, + "price_cents": 5200, }, { - "name": "Express Add-On", + "name": "Area Rug Refresh", "slug": "express", - "description": "Rush the job (addon).", - "duration_minutes": 0, - "price_cents": 1000, + "category_slug": "rugs-textiles", + "description": "Premium refresh care for rugs and home textiles.", + "duration_minutes": 45, + "price_cents": 6800, + }, + { + "name": "Hemming & Minor Alterations", + "slug": "alterations", + "category_slug": "alterations", + "description": "Tailoring, small repairs, and fit adjustments.", + "duration_minutes": 60, + "price_cents": 3000, }, ] +def ensure_baseline_care_categories(session: Session) -> dict[str, CareCategory]: + """Ensure baseline care categories exist for local, test, and upgraded databases.""" + + existing = { + category.slug: category + for category in session.execute(select(CareCategory)).scalars().all() + } + for payload in BASELINE_CARE_CATEGORIES: + slug = str(payload["slug"]) + category = existing.get(slug) + if category is None: + category = CareCategory(**payload) + session.add(category) + existing[slug] = category + else: + category.name = str(payload["name"]) + category.description = payload.get("description") + category.sort_order = int(payload["sort_order"]) + category.icon_key = payload.get("icon_key") + category.is_active = True + session.flush() + return existing + + def seed_services(session: Session) -> int: - """Insert default services if the table is empty.""" + """Insert default services if empty and backfill legacy services to shoes.""" + + categories = ensure_baseline_care_categories(session) + shoes_category = categories["shoes"] + + session.query(Service).filter(Service.category_id.is_(None)).update( + {Service.category_id: shoes_category.id}, + synchronize_session=False, + ) existing = session.execute(select(Service.id)).first() if existing is not None: + session.commit() return 0 company = session.execute(select(Company).limit(1)).scalar_one_or_none() @@ -62,7 +108,9 @@ def seed_services(session: Session) -> int: session.flush() for payload in SEED_SERVICES: - session.add(Service(company_id=company.id, **payload)) + category_slug = str(payload["category_slug"]) + service_payload = {key: value for key, value in payload.items() if key != "category_slug"} + session.add(Service(company_id=company.id, category_id=categories[category_slug].id, **service_payload)) session.commit() return len(SEED_SERVICES) diff --git a/apps/api/tests/test_dev_seed.py b/apps/api/tests/test_dev_seed.py index 1900f06..c1934d7 100644 --- a/apps/api/tests/test_dev_seed.py +++ b/apps/api/tests/test_dev_seed.py @@ -1,7 +1,26 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from app.models import Appointment, AppointmentAssignment, Company, User +from app.models import ( + Appointment, + AppointmentAssignment, + CareCategory, + Company, + CompanyCareCategory, + ProviderCareCategory, + Service, + User, +) + + +REQUIRED_CATEGORY_SLUGS = { + "shoes", + "laundry", + "dry-cleaning", + "handbags-leather", + "rugs-textiles", + "alterations", +} EXPECTED_COMPANY_ADDRESSES = { @@ -35,6 +54,50 @@ } +def _market_companies(db_session: Session, company_names: set[str]) -> list[Company]: + return db_session.query(Company).filter(Company.name.in_(company_names)).all() + + +def _market_service_category_slugs(db_session: Session, company_names: set[str]) -> set[str]: + companies = _market_companies(db_session, company_names) + company_ids = [company.id for company in companies] + rows = ( + db_session.query(CareCategory.slug) + .join(Service, Service.category_id == CareCategory.id) + .filter(Service.company_id.in_(company_ids)) + .distinct() + .all() + ) + return {row[0] for row in rows} + + +def _assert_market_has_required_categories(db_session: Session, company_names: set[str]) -> None: + companies = _market_companies(db_session, company_names) + company_ids = [company.id for company in companies] + assert len(companies) == 3 + + services = db_session.query(Service).filter(Service.company_id.in_(company_ids)).all() + assert services + assert all(service.category_id is not None for service in services) + assert _market_service_category_slugs(db_session, company_names) == REQUIRED_CATEGORY_SLUGS + + company_category_count = ( + db_session.query(CompanyCareCategory) + .filter(CompanyCareCategory.company_id.in_(company_ids), CompanyCareCategory.is_active.is_(True)) + .count() + ) + assert company_category_count >= len(REQUIRED_CATEGORY_SLUGS) + + provider_ids = [ + user.id + for user in db_session.query(User) + .join(AppointmentAssignment, AppointmentAssignment.user_id == User.id, isouter=True) + .filter(User.role.in_(["provider", "company_admin"])) + .all() + ] + assert db_session.query(ProviderCareCategory).filter(ProviderCareCategory.provider_id.in_(provider_ids)).count() > 0 + + def test_dev_seed_creates_assignments_without_company_id_and_reset_reseeds( db_session: Session, client: TestClient, @@ -105,7 +168,7 @@ def test_dev_seed_populates_realistic_city_aligned_addresses( .filter(Appointment.company_id.in_(company_by_id)) .all() ) - assert len(appointments) == 8 + assert len(appointments) == 9 assert all(appointment.address_line1 for appointment in appointments) assert all(appointment.city and appointment.state and appointment.postal_code for appointment in appointments) cluster_cities = {"Pelham", "Helena", "Alabaster"} @@ -131,6 +194,44 @@ def test_dev_seed_populates_realistic_city_aligned_addresses( assert customer.address_line1 in distinct_appointment_addresses +def test_dev_seed_shelby_market_has_multi_category_services_and_filters( + db_session: Session, + client: TestClient, +) -> None: + response = client.post("/dev/seed?reset=true") + assert response.status_code == 200, response.text + + company_names = set(EXPECTED_COMPANY_ADDRESSES.keys()) + _assert_market_has_required_categories(db_session, company_names) + + for category_slug in REQUIRED_CATEGORY_SLUGS: + services_response = client.get("/services", params={"category_slug": category_slug}) + assert services_response.status_code == 200, services_response.text + services = services_response.json() + assert services + assert {service["category_slug"] for service in services} == {category_slug} + + companies_response = client.get("/companies", params={"category_slug": category_slug, "state": "AL"}) + assert companies_response.status_code == 200, companies_response.text + companies = companies_response.json() + assert companies + assert all( + category_slug in {category["slug"] for category in company["offered_categories"]} + for company in companies + ) + + shoe_services = client.get("/services", params={"category_slug": "shoes"}).json() + assert any("Sneaker" in service["name"] or "White Pair" in service["name"] for service in shoe_services) + + first_company = _market_companies(db_session, company_names)[0] + company_service_response = client.get( + f"/companies/{first_company.id}/services", + params={"category_slug": "shoes"}, + ) + assert company_service_response.status_code == 200, company_service_response.text + assert all(service["category_slug"] == "shoes" for service in company_service_response.json()) + + def test_dev_seed_mt_juliet_selector_resets_other_demo_markets_and_rotates_selected_pool( db_session: Session, client: TestClient, @@ -173,7 +274,7 @@ def test_dev_seed_mt_juliet_selector_resets_other_demo_markets_and_rotates_selec .filter(Appointment.company_id.in_(company_by_id)) .all() ) - assert len(appointments) == 8 + assert len(appointments) == 9 assert all(appointment.address_line1 for appointment in appointments) assert all(appointment.city == "Mt. Juliet" for appointment in appointments) assert all(appointment.state == "TN" for appointment in appointments) @@ -183,6 +284,34 @@ def test_dev_seed_mt_juliet_selector_resets_other_demo_markets_and_rotates_selec for appointment in appointments } == EXPECTED_MT_JULIET_CUSTOMER_JOB_ADDRESSES + _assert_market_has_required_categories(db_session, set(EXPECTED_MT_JULIET_COMPANY_ADDRESSES.keys())) + + +def test_dev_seed_mt_juliet_market_has_multi_category_services_and_reset_is_stable( + db_session: Session, + client: TestClient, +) -> None: + first = client.post("/dev/seed?reset=true&demo_market=mt_juliet") + assert first.status_code == 200, first.text + + second = client.post("/dev/seed?reset=true&demo_market=mt_juliet") + assert second.status_code == 200, second.text + assert second.json()["created"]["company_categories"] > 0 + assert second.json()["created"]["provider_categories"] > 0 + + company_names = set(EXPECTED_MT_JULIET_COMPANY_ADDRESSES.keys()) + _assert_market_has_required_categories(db_session, company_names) + + for category_slug in REQUIRED_CATEGORY_SLUGS: + companies_response = client.get("/companies", params={"category_slug": category_slug, "state": "TN"}) + assert companies_response.status_code == 200, companies_response.text + companies = companies_response.json() + assert companies + assert all( + category_slug in {category["slug"] for category in company["offered_categories"]} + for company in companies + ) + def test_dev_seed_mt_juliet_quick_demo_users_have_expected_roles( db_session: Session, diff --git a/apps/api/tests/test_premium_care_marketplace_flows.py b/apps/api/tests/test_premium_care_marketplace_flows.py new file mode 100644 index 0000000..9735e3c --- /dev/null +++ b/apps/api/tests/test_premium_care_marketplace_flows.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from uuid import UUID + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.core.security import create_access_token, hash_password +from app.models import CompanyUser, Notification, User +from app.routers.appointments import get_payment_gateway +from app.services.payment_gateway import CheckoutSession, PaymentRecord + + +def _auth_header(user: User, *, company_id: UUID | None = None) -> dict[str, str]: + payload = {"sub": str(user.id), "role": user.role} + if company_id is not None: + payload["company_id"] = str(company_id) + token = create_access_token(payload) + return {"Authorization": f"Bearer {token}"} + + +def _access_token(user: User, *, company_id: UUID | None = None) -> str: + return _auth_header(user, company_id=company_id)["Authorization"].split(" ", 1)[1] + + +def _make_user(db: Session, *, email: str, role: str, full_name: str) -> User: + user = User(email=email, full_name=full_name, role=role, password_hash=hash_password("Password1!")) + db.add(user) + db.flush() + return user + + +def _pick_service(client: TestClient, *, category_slug: str) -> dict: + response = client.get("/services", params={"category_slug": category_slug}) + assert response.status_code == 200, response.text + services = response.json() + assert services + return services[0] + + +def _start_time(days: int = 1, hour: int = 10) -> datetime: + return datetime.now(timezone.utc).replace(hour=hour, minute=0, second=0, microsecond=0) + timedelta(days=days) + + +def _create_hold(client: TestClient, service_id: str, start_time: datetime, *, customer_email: str | None = None) -> str: + response = client.post( + "/appointments/holds", + json={ + "service_id": service_id, + "start_time": start_time.isoformat(), + "customer_email": customer_email, + }, + ) + assert response.status_code == 201, response.text + return response.json()["id"] + + +def _confirm_hold( + client: TestClient, + *, + hold_id: str, + company_id: str, + customer_name: str, + customer_email: str, +) -> dict: + response = client.post( + "/appointments/confirm", + json={ + "hold_id": hold_id, + "company_id": company_id, + "customer_name": customer_name, + "customer_phone": "5551234567", + "customer_email": customer_email, + }, + ) + assert response.status_code == 200, response.text + return response.json() + + +class FakeServiceGateway: + def __init__(self) -> None: + self.mode = "service" + self.enabled = True + self.payment_status = "pending" + + def create_checkout_session( + self, + *, + booking_id: str, + amount_cents: int, + currency: str, + customer_email: str | None, + customer_name: str | None, + ) -> CheckoutSession: + del amount_cents, currency, customer_email, customer_name + return CheckoutSession( + payment_id=f"pay_{booking_id}", + checkout_session_id=f"cs_{booking_id}", + checkout_url=f"https://checkout.stripe.test/{booking_id}", + status="pending", + ) + + def fetch_payment(self, *, booking_id: str) -> PaymentRecord: + return PaymentRecord( + payment_id=f"pay_{booking_id}", + booking_id=booking_id, + status=self.payment_status, + amount_expected=1000, + amount_received=1000 if self.payment_status == "succeeded" else None, + currency="usd", + ) + + +def test_existing_shoe_quote_hold_confirm_and_filters_keep_category_metadata( + client: TestClient, +) -> None: + service = _pick_service(client, category_slug="shoes") + start_time = _start_time(hour=9) + + quote_response = client.post( + "/appointments/quote", + json={ + "service_id": service["id"], + "start_time": start_time.isoformat(), + "type": "pickup", + }, + ) + assert quote_response.status_code == 200, quote_response.text + quote = quote_response.json() + assert quote["service_id"] == service["id"] + assert quote["service_name"] == service["name"] + assert quote["category_id"] == service["category_id"] + assert quote["category_slug"] == "shoes" + assert quote["category_name"] == service["category_name"] + + hold_id = _create_hold(client, service["id"], start_time) + appointment = _confirm_hold( + client, + hold_id=hold_id, + company_id=service["company_id"], + customer_name="Shoe Regression Customer", + customer_email="shoe-regression@example.com", + ) + + assert appointment["service_id"] == service["id"] + assert appointment["service_name"] == service["name"] + assert appointment["category_slug"] == "shoes" + assert appointment["status"] == "confirmed" + assert appointment["payment_status"] == "succeeded" + + company_services = client.get( + f"/companies/{service['company_id']}/services", + params={"category_slug": "shoes"}, + ) + assert company_services.status_code == 200, company_services.text + assert any(item["id"] == service["id"] for item in company_services.json()) + + +def test_non_shoe_service_mode_payment_refresh_preserves_category_metadata( + client: TestClient, + db_session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _pick_service(client, category_slug="dry-cleaning") + customer = _make_user( + db_session, + email="dry-cleaning-payment@example.com", + role="customer", + full_name="Dry Cleaning Customer", + ) + db_session.commit() + + gateway = FakeServiceGateway() + client.app.dependency_overrides[get_payment_gateway] = lambda: gateway + monkeypatch.setattr(settings, "payment_mode", "service") + + hold_id = _create_hold(client, service["id"], _start_time(hour=11), customer_email=customer.email) + appointment = _confirm_hold( + client, + hold_id=hold_id, + company_id=service["company_id"], + customer_name="Dry Cleaning Customer", + customer_email=customer.email, + ) + + assert appointment["status"] == "pending_payment" + assert appointment["payment_status"] == "pending" + assert appointment["payment_checkout_url"].startswith("https://checkout.stripe.test/") + assert appointment["category_slug"] == "dry-cleaning" + assert appointment["category_icon_key"] == service["category_icon_key"] + + gateway.payment_status = "succeeded" + refresh = client.post( + f"/appointments/{appointment['id']}/payment/refresh", + headers=_auth_header(customer), + ) + assert refresh.status_code == 200, refresh.text + refreshed = refresh.json() + assert refreshed["status"] == "confirmed" + assert refreshed["payment_status"] == "succeeded" + assert refreshed["payment_checkout_url"] is None + assert refreshed["category_slug"] == "dry-cleaning" + + +def test_non_shoe_assignment_status_notifications_live_events_and_appointment_views( + client: TestClient, + db_session: Session, +) -> None: + service = _pick_service(client, category_slug="laundry") + customer = _make_user( + db_session, + email="laundry-customer@example.com", + role="customer", + full_name="Laundry Customer", + ) + provider = _make_user( + db_session, + email="laundry-provider@example.com", + role="provider", + full_name="Laundry Provider", + ) + admin = _make_user( + db_session, + email="laundry-admin@example.com", + role="company_admin", + full_name="Laundry Admin", + ) + company_id = UUID(service["company_id"]) + db_session.add_all( + [ + CompanyUser(user_id=provider.id, company_id=company_id), + CompanyUser(user_id=admin.id, company_id=company_id), + ] + ) + db_session.commit() + + hold_id = _create_hold(client, service["id"], _start_time(days=2, hour=12), customer_email=customer.email) + appointment = _confirm_hold( + client, + hold_id=hold_id, + company_id=service["company_id"], + customer_name="Laundry Customer", + customer_email=customer.email, + ) + assert appointment["status"] == "confirmed" + assert appointment["category_slug"] == "laundry" + + claim = client.post( + f"/company/appointments/{appointment['id']}/claim", + headers=_auth_header(provider, company_id=company_id), + ) + assert claim.status_code == 201, claim.text + claim_payload = claim.json() + assert claim_payload["provider_name"] == "Laundry Provider" + assert claim_payload["service_name"] == service["name"] + assert claim_payload["category_slug"] == "laundry" + + token = _access_token(customer) + with client.websocket_connect(f"/live/ws?token={token}") as websocket: + status_response = client.post( + f"/company/appointments/{appointment['id']}/status", + json={"status": "en_route_pickup"}, + headers=_auth_header(provider, company_id=company_id), + ) + assert status_response.status_code == 200, status_response.text + live_payload = websocket.receive_json() + + assert live_payload["type"] == "appointment_status_changed" + assert live_payload["appointment_id"] == appointment["id"] + assert live_payload["previous_status"] == "confirmed" + assert live_payload["status"] == "en_route_pickup" + assert live_payload["actor_role"] == "provider" + assert live_payload["service_name"] == service["name"] + assert live_payload["category_slug"] == "laundry" + + customer_detail = client.get(f"/appointments/{appointment['id']}", headers=_auth_header(customer)) + assert customer_detail.status_code == 200, customer_detail.text + detail_payload = customer_detail.json() + assert detail_payload["status"] == "en_route_pickup" + assert detail_payload["category_slug"] == "laundry" + + mine = client.get("/appointments/mine", headers=_auth_header(customer)) + assert mine.status_code == 200, mine.text + mine_item = next(item for item in mine.json() if item["id"] == appointment["id"]) + assert mine_item["status"] == "en_route_pickup" + assert mine_item["service_id"] == service["id"] + assert mine_item["category_slug"] == "laundry" + + provider_jobs = client.get("/company/appointments/my", headers=_auth_header(provider, company_id=company_id)) + assert provider_jobs.status_code == 200, provider_jobs.text + provider_item = next(item for item in provider_jobs.json() if item["id"] == appointment["id"]) + assert provider_item["category_slug"] == "laundry" + assert provider_item["service_name"] == service["name"] + + admin_jobs = client.get("/company/appointments/open", headers=_auth_header(admin, company_id=company_id)) + assert admin_jobs.status_code == 200, admin_jobs.text + admin_item = next(item for item in admin_jobs.json() if item["id"] == appointment["id"]) + assert admin_item["category_slug"] == "laundry" + assert admin_item["provider_name"] == "Laundry Provider" + + notification = ( + db_session.query(Notification) + .filter( + Notification.appointment_id == UUID(appointment["id"]), + Notification.kind == "APPOINTMENT_STATUS_CHANGED", + Notification.channel == "in_app", + Notification.target == str(customer.id), + ) + .order_by(Notification.created_at.desc()) + .first() + ) + assert notification is not None + assert notification.payload_json["new_status"] == "en_route_pickup" + assert notification.payload_json["service_name"] == service["name"] + assert notification.payload_json["category_slug"] == "laundry" diff --git a/apps/api/tests/test_services.py b/apps/api/tests/test_services.py index 3e11190..4ac205b 100644 --- a/apps/api/tests/test_services.py +++ b/apps/api/tests/test_services.py @@ -1,6 +1,13 @@ from __future__ import annotations from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import BASELINE_CARE_CATEGORIES, CareCategory, Company, Service +from scripts.seed_services import seed_services + + +BASELINE_CATEGORY_SLUGS = {str(category["slug"]) for category in BASELINE_CARE_CATEGORIES} def test_services_seeded(client: TestClient) -> None: @@ -12,10 +19,17 @@ def test_services_seeded(client: TestClient) -> None: names = [item["name"] for item in data] assert names == sorted(names) + category_slugs = {item["category_slug"] for item in data} + assert category_slugs == BASELINE_CATEGORY_SLUGS + for item in data: assert set( [ "id", + "category_id", + "category_slug", + "category_name", + "category_icon_key", "name", "slug", "description", @@ -26,3 +40,165 @@ def test_services_seeded(client: TestClient) -> None: "updated_at", ] ).issubset(item.keys()) + assert item["category_id"] + assert item["category_slug"] in BASELINE_CATEGORY_SLUGS + assert item["category_name"] + + +def test_baseline_care_categories_seeded(client: TestClient, db_session: Session) -> None: + response = client.get("/services") + assert response.status_code == 200 + + categories = db_session.query(CareCategory).order_by(CareCategory.sort_order.asc()).all() + assert {category.slug for category in categories} == BASELINE_CATEGORY_SLUGS + assert [category.slug for category in categories][:2] == ["shoes", "laundry"] + assert all(category.is_active for category in categories) + + +def test_care_categories_endpoint_returns_active_categories(client: TestClient, db_session: Session) -> None: + inactive = db_session.query(CareCategory).filter(CareCategory.slug == "alterations").one() + inactive.is_active = False + db_session.commit() + + response = client.get("/care-categories") + + assert response.status_code == 200 + data = response.json() + slugs = [category["slug"] for category in data] + assert "alterations" not in slugs + assert {"shoes", "laundry", "dry-cleaning", "handbags-leather", "rugs-textiles"}.issubset(slugs) + assert slugs[:2] == ["shoes", "laundry"] + assert set(["id", "slug", "name", "description", "icon_key", "display_order", "is_active"]).issubset( + data[0].keys() + ) + assert "sort_order" not in data[0] + + +def test_seeded_services_have_category_metadata(client: TestClient, db_session: Session) -> None: + response = client.get("/services") + assert response.status_code == 200 + + services = db_session.query(Service).all() + assert services + assert all(service.category_id is not None for service in services) + assert {service.category.slug for service in services} == BASELINE_CATEGORY_SLUGS + + +def test_services_can_filter_by_category_slug(client: TestClient) -> None: + response = client.get("/services", params={"category_slug": "shoes"}) + + assert response.status_code == 200 + data = response.json() + assert data + assert {item["category_slug"] for item in data} == {"shoes"} + + +def test_services_can_filter_by_category_id(client: TestClient, db_session: Session) -> None: + shoes = db_session.query(CareCategory).filter(CareCategory.slug == "shoes").one() + + response = client.get("/services", params={"category_id": str(shoes.id)}) + + assert response.status_code == 200 + data = response.json() + assert data + assert {item["category_id"] for item in data} == {str(shoes.id)} + + +def test_services_unknown_category_filter_returns_empty(client: TestClient) -> None: + response = client.get("/services", params={"category_slug": "not-a-category"}) + + assert response.status_code == 200 + assert response.json() == [] + + +def test_service_filters_preserve_company_filter(client: TestClient, db_session: Session) -> None: + company = db_session.query(Company).first() + assert company is not None + + response = client.get( + "/services", + params={ + "company_id": str(company.id), + "category_slug": "shoes", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data + assert {item["company_id"] for item in data} == {str(company.id)} + assert {item["category_slug"] for item in data} == {"shoes"} + + +def test_company_discovery_can_filter_by_category(client: TestClient, db_session: Session) -> None: + company = db_session.query(Company).first() + laundry = db_session.query(CareCategory).filter(CareCategory.slug == "laundry").one() + assert company is not None + db_session.add( + Service( + company_id=company.id, + category_id=laundry.id, + name="Laundry Care Test", + slug="laundry-care-test", + description="Phase 3 category filter test service.", + duration_minutes=45, + price_cents=2500, + ) + ) + db_session.commit() + + response = client.get("/companies", params={"category_slug": "laundry"}) + + assert response.status_code == 200 + data = response.json() + assert [item["id"] for item in data] == [str(company.id)] + offered_slugs = {category["slug"] for category in data[0]["offered_categories"]} + assert {"shoes", "laundry"}.issubset(offered_slugs) + + +def test_company_services_can_filter_by_category(client: TestClient, db_session: Session) -> None: + company = db_session.query(Company).first() + laundry = db_session.query(CareCategory).filter(CareCategory.slug == "laundry").one() + assert company is not None + db_session.add( + Service( + company_id=company.id, + category_id=laundry.id, + name="Laundry Detail Test", + slug="laundry-detail-test", + description="Phase 3 company service category filter test.", + duration_minutes=45, + price_cents=2500, + ) + ) + db_session.commit() + + response = client.get(f"/companies/{company.id}/services", params={"category_id": str(laundry.id)}) + + assert response.status_code == 200 + data = response.json() + assert data + assert {item["category_slug"] for item in data} == {"laundry"} + + +def test_seed_services_backfills_existing_uncategorized_services(db_session: Session) -> None: + company = Company(name="Legacy ShoeInn", city="Mt. Juliet", state="TN") + db_session.add(company) + db_session.flush() + service = Service( + company_id=company.id, + name="Legacy Premium Restore", + slug="legacy-premium-restore", + description="Existing shoe-care service without category metadata.", + duration_minutes=60, + price_cents=4500, + ) + db_session.add(service) + db_session.commit() + + created = seed_services(db_session) + + assert created == 0 + db_session.refresh(service) + assert service.category_id is not None + assert service.category.slug == "shoes" diff --git a/apps/mobile/.easignore b/apps/mobile/.easignore index baf2d23..6e5f9e1 100644 --- a/apps/mobile/.easignore +++ b/apps/mobile/.easignore @@ -10,4 +10,6 @@ **/*.pyc **/.venv/ **/venv/ -**/node_modules/ \ No newline at end of file +**/node_modules/ +**/.mypy_cache +**/.ruff_cache \ No newline at end of file diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 0ceeea6..c475dc8 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -11,18 +11,27 @@ const showDemoLogins = process.env.EXPO_PUBLIC_ENABLE_DEMO_LOGINS === "true" || process.env.SHOW_DEMO_LOGINS === "true"; +const demoMarket = process.env.EXPO_PUBLIC_DEMO_MARKET ?? "shelby"; + export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, name: "Shoeinn", slug: "shoeinn", scheme: "shoeinn", - version: "1.0.0", + version: "1.0.1", userInterfaceStyle: "light", orientation: "portrait", platforms: ["ios", "android", "web"], android: { ...config.android, package: "com.mrwrite.shoeinn", + versionCode: 2, + config: { + ...config.android?.config, + googleMaps: { + apiKey: process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY, + }, + }, }, updates: { ...config.updates, @@ -38,6 +47,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ? { MOBILE_REDIRECT_BASE: configuredMobileRedirectBase } : {}), SHOW_DEMO_LOGINS: showDemoLogins, + DEMO_MARKET: demoMarket, eas: { ...config.extra?.eas, projectId: "1a753a1a-ae23-47e9-ba06-cc6148fb36ee", diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 33652f3..126d2a9 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -20,7 +20,9 @@ "env": { "EXPO_PUBLIC_API_URL": "https://api-shoeinn.mrwrite.dev", "EXPO_PUBLIC_APP_ENV": "staging", - "EXPO_PUBLIC_ENABLE_DEMO_LOGINS": "true" + "EXPO_PUBLIC_ENABLE_DEMO_LOGINS": "true", + "EXPO_PUBLIC_GOOGLE_MAPS_API_KEY": "AIzaSyBuGdJpuCV5rmcJQ_Hrg7Zkd0ajL6XUglw", + "EXPO_PUBLIC_MOBILE_REDIRECT_BASE": "shoeinn://payment-return" } }, "production": { diff --git a/apps/mobile/src/__tests__/appointmentCategoryNeutralCopy.test.ts b/apps/mobile/src/__tests__/appointmentCategoryNeutralCopy.test.ts new file mode 100644 index 0000000..14c3faa --- /dev/null +++ b/apps/mobile/src/__tests__/appointmentCategoryNeutralCopy.test.ts @@ -0,0 +1,20 @@ +import { + customerAppointmentNextStepCopy, + customerAppointmentStatusLabels, + getReadableAppointmentStatus, +} from "../features/appointmentCopy"; + +describe("category-neutral appointment copy", () => { + it("uses care/order language for active lifecycle statuses", () => { + expect(customerAppointmentStatusLabels.cleaning).toBe("In care"); + expect(customerAppointmentStatusLabels.ready).toBe("Ready for return"); + expect(customerAppointmentNextStepCopy.cleaning).toBe("Your items are currently in care."); + expect(customerAppointmentNextStepCopy.ready).toBe("Your order is ready for return."); + }); + + it("formats shared appointment status badges without shoe-only or cleaning-only wording", () => { + expect(getReadableAppointmentStatus("cleaning")).toBe("In care"); + expect(getReadableAppointmentStatus("ready")).toBe("Ready for return"); + expect(getReadableAppointmentStatus("out_for_delivery")).toBe("Out for delivery"); + }); +}); diff --git a/apps/mobile/src/__tests__/bookingCheckout.test.ts b/apps/mobile/src/__tests__/bookingCheckout.test.ts index 3629c19..9f8b503 100644 --- a/apps/mobile/src/__tests__/bookingCheckout.test.ts +++ b/apps/mobile/src/__tests__/bookingCheckout.test.ts @@ -5,6 +5,10 @@ describe("buildQuoteDisplayRows", () => { const rows = buildQuoteDisplayRows({ service_id: "svc_123", service_name: "Deluxe Clean", + category_id: "cat_laundry", + category_slug: "laundry", + category_name: "Laundry", + category_icon_key: "shirt", currency: "usd", subtotal: 6500, fees: 1098, diff --git a/apps/mobile/src/__tests__/customerNotificationsGrouping.test.ts b/apps/mobile/src/__tests__/customerNotificationsGrouping.test.ts index 789aac7..8312ff0 100644 --- a/apps/mobile/src/__tests__/customerNotificationsGrouping.test.ts +++ b/apps/mobile/src/__tests__/customerNotificationsGrouping.test.ts @@ -7,6 +7,7 @@ jest.mock("../api/http", () => ({ })); import { + getCustomerNotificationCopy, getLatestNotificationForAppointment, groupCustomerNotifications, } from "../hooks/useCustomerNotifications"; @@ -81,4 +82,16 @@ describe("customer notification grouping", () => { expect(getLatestNotificationForAppointment(notifications, "appt-1")?.id).toBe("delivery"); }); + + it("uses category-neutral status copy for in-care updates", () => { + const copy = getCustomerNotificationCopy( + makeNotification({ + kind: "APPOINTMENT_STATUS_CHANGED", + payload: { new_status: "cleaning", category_slug: "laundry", category_name: "Laundry" }, + }), + ); + + expect(copy.title).toBe("In care update"); + expect(copy.detail).toBe("Your order is now in care."); + }); }); diff --git a/apps/mobile/src/__tests__/demoLogins.test.ts b/apps/mobile/src/__tests__/demoLogins.test.ts index 5addfa2..cf2fbf5 100644 --- a/apps/mobile/src/__tests__/demoLogins.test.ts +++ b/apps/mobile/src/__tests__/demoLogins.test.ts @@ -1,4 +1,12 @@ -import { MT_JULIET_DEMO_ACCOUNTS, shouldShowDemoLogins } from "../auth/demoLogins"; +import { + getDemoLoginAccounts, + getDemoMarket, + getDemoMarketDiscoveryLocation, + getDemoMarketLabel, + MT_JULIET_DEMO_ACCOUNTS, + SHELBY_DEMO_ACCOUNTS, + shouldShowDemoLogins, +} from "../auth/demoLogins"; describe("demo login helpers", () => { it("shows demo logins when the flag is enabled", () => { @@ -9,8 +17,43 @@ describe("demo login helpers", () => { expect(shouldShowDemoLogins(false)).toBe(false); }); - it("uses the expected Mt. Juliet credentials", () => { - expect(MT_JULIET_DEMO_ACCOUNTS).toEqual([ + it("uses Shelby credentials by default", () => { + expect(getDemoMarket(undefined)).toBe("shelby"); + expect(getDemoMarketLabel("shelby")).toBe("Shelby County"); + expect(getDemoMarketDiscoveryLocation("shelby")).toEqual({ + label: "Shelby County, AL", + city: null, + state: "AL", + }); + expect(getDemoLoginAccounts("shelby")).toEqual([ + { + label: "Shelby Customer", + email: "customer@shoeinn.com", + password: "Password1!", + }, + { + label: "Shelby Provider", + email: "pelham.driver1@shoeinn.com", + password: "Password1!", + }, + { + label: "Shelby Company Admin", + email: "pelham.admin@shoeinn.com", + password: "Password1!", + }, + ]); + expect(SHELBY_DEMO_ACCOUNTS).toEqual(getDemoLoginAccounts("shelby")); + }); + + it("uses the expected Mt. Juliet credentials when selected", () => { + expect(getDemoMarket("mt_juliet")).toBe("mt_juliet"); + expect(getDemoMarketLabel("mt_juliet")).toBe("Mt. Juliet"); + expect(getDemoMarketDiscoveryLocation("mt_juliet")).toEqual({ + label: "Mt. Juliet, TN", + city: "Mt. Juliet", + state: "TN", + }); + expect(getDemoLoginAccounts("mt_juliet")).toEqual([ { label: "Mt. Juliet Customer", email: "customer.mtjuliet@shoeinn.demo", @@ -27,5 +70,6 @@ describe("demo login helpers", () => { password: "Password123!", }, ]); + expect(MT_JULIET_DEMO_ACCOUNTS).toEqual(getDemoLoginAccounts("mt_juliet")); }); }); diff --git a/apps/mobile/src/api/http.test.ts b/apps/mobile/src/api/http.test.ts new file mode 100644 index 0000000..4c803c7 --- /dev/null +++ b/apps/mobile/src/api/http.test.ts @@ -0,0 +1,40 @@ +jest.mock("expo-constants", () => ({ + __esModule: true, + default: { + expoGoConfig: undefined, + expoConfig: undefined, + }, +})); + +const http = require("./http") as typeof import("./http"); + +describe("mobile API discovery paths", () => { + it("builds unfiltered service discovery path", () => { + expect(http.buildServicesPath()).toBe("/services"); + }); + + it("preserves legacy company service discovery path", () => { + expect(http.buildServicesPath("company-1")).toBe("/services?company_id=company-1"); + }); + + it("builds category-aware service discovery path", () => { + expect(http.buildServicesPath({ companyId: "company-1", categorySlug: "dry-cleaning" })).toBe( + "/services?company_id=company-1&category_slug=dry-cleaning", + ); + }); + + it("builds unfiltered company discovery path", () => { + expect(http.buildCompaniesPath()).toBe("/companies"); + }); + + it("builds category-aware company discovery path", () => { + expect( + http.buildCompaniesPath({ + city: "Mt. Juliet", + state: "TN", + query: "care", + categorySlug: "handbags-leather", + }), + ).toBe("/companies?query=care&city=Mt.+Juliet&state=TN&category_slug=handbags-leather"); + }); +}); diff --git a/apps/mobile/src/api/http.ts b/apps/mobile/src/api/http.ts index 0d25f63..1c59fce 100644 --- a/apps/mobile/src/api/http.ts +++ b/apps/mobile/src/api/http.ts @@ -21,6 +21,7 @@ import type { import type { LoginPayload, LoginResponse, RegisterPayload, RegisterResponse } from "../types/auth"; import type { Company, CompanyUser, CompanyUserCreateResponse } from "../types/company"; import type { ProviderAppointment, StatusUpdatePayload } from "../types/company"; +import type { CareCategory } from "../types/care"; import { getAuthToken } from "../state/authStore"; import type { Notification } from "../types/notification"; import type { PushRegisterRequest, PushUnregisterRequest } from "../types/push"; @@ -141,28 +142,55 @@ export function register(payload: RegisterPayload): Promise { return request("POST", "/auth/register", payload); } -export function listServices(companyId?: string): Promise { - const search = companyId ? `?company_id=${encodeURIComponent(companyId)}` : ""; - return request("GET", `/services${search}`); +interface ListServicesParams { + companyId?: string; + categorySlug?: string | null; + categoryId?: string | null; +} + +export function buildServicesPath(paramsOrCompanyId?: string | ListServicesParams): string { + const search = new URLSearchParams(); + const params = typeof paramsOrCompanyId === "string" ? { companyId: paramsOrCompanyId } : paramsOrCompanyId; + + if (params?.companyId) search.set("company_id", params.companyId); + if (params?.categorySlug) search.set("category_slug", params.categorySlug); + if (params?.categoryId) search.set("category_id", params.categoryId); + + const suffix = search.toString(); + return suffix ? `/services?${suffix}` : "/services"; +} + +export function listCareCategories(): Promise { + return request("GET", "/care-categories"); +} + +export function listServices(paramsOrCompanyId?: string | ListServicesParams): Promise { + return request("GET", buildServicesPath(paramsOrCompanyId)); } interface ListCompaniesParams { query?: string; city?: string | null; state?: string | null; + categorySlug?: string | null; + categoryId?: string | null; } -export function listCompanies(params: ListCompaniesParams = {}): Promise { +export function buildCompaniesPath(params: ListCompaniesParams = {}): string { const search = new URLSearchParams(); if (params.query) search.set("query", params.query); if (params.city) search.set("city", params.city); if (params.state) search.set("state", params.state); + if (params.categorySlug) search.set("category_slug", params.categorySlug); + if (params.categoryId) search.set("category_id", params.categoryId); const suffix = search.toString(); - const path = suffix ? `/companies?${suffix}` : "/companies"; + return suffix ? `/companies?${suffix}` : "/companies"; +} - return request("GET", path); +export function listCompanies(params: ListCompaniesParams = {}): Promise { + return request("GET", buildCompaniesPath(params)); } export function getJson(path: string): Promise { diff --git a/apps/mobile/src/api/services.ts b/apps/mobile/src/api/services.ts index b03b25c..f69f1cb 100644 --- a/apps/mobile/src/api/services.ts +++ b/apps/mobile/src/api/services.ts @@ -8,6 +8,10 @@ export type ServiceStatus = 'active' | 'inactive'; export interface Service { id: string; + category_id?: string | null; + category_slug?: string | null; + category_name?: string | null; + category_icon_key?: string | null; name: string; description?: string | null; price: number; diff --git a/apps/mobile/src/auth/demoLogins.ts b/apps/mobile/src/auth/demoLogins.ts index 9fedd37..50eba41 100644 --- a/apps/mobile/src/auth/demoLogins.ts +++ b/apps/mobile/src/auth/demoLogins.ts @@ -4,6 +4,32 @@ export type DemoLoginAccount = { password: string; }; +export type DemoMarket = "shelby" | "mt_juliet"; + +export type DemoMarketDiscoveryLocation = { + label: string; + city: string | null; + state: string; +}; + +export const SHELBY_DEMO_ACCOUNTS: DemoLoginAccount[] = [ + { + label: "Shelby Customer", + email: "customer@shoeinn.com", + password: "Password1!", + }, + { + label: "Shelby Provider", + email: "pelham.driver1@shoeinn.com", + password: "Password1!", + }, + { + label: "Shelby Company Admin", + email: "pelham.admin@shoeinn.com", + password: "Password1!", + }, +]; + export const MT_JULIET_DEMO_ACCOUNTS: DemoLoginAccount[] = [ { label: "Mt. Juliet Customer", @@ -22,6 +48,29 @@ export const MT_JULIET_DEMO_ACCOUNTS: DemoLoginAccount[] = [ }, ]; +export const DEMO_ACCOUNTS_BY_MARKET: Record = { + shelby: SHELBY_DEMO_ACCOUNTS, + mt_juliet: MT_JULIET_DEMO_ACCOUNTS, +}; + +export const DEMO_MARKET_LABELS: Record = { + shelby: "Shelby County", + mt_juliet: "Mt. Juliet", +}; + +export const DEMO_MARKET_DISCOVERY_LOCATIONS: Record = { + shelby: { + label: "Shelby County, AL", + city: null, + state: "AL", + }, + mt_juliet: { + label: "Mt. Juliet, TN", + city: "Mt. Juliet", + state: "TN", + }, +}; + function readFlagFromRuntime(): boolean { let extraFlag: boolean | string | undefined; try { @@ -44,3 +93,41 @@ function readFlagFromRuntime(): boolean { export function shouldShowDemoLogins(flag = readFlagFromRuntime()): boolean { return flag; } + +function normalizeDemoMarket(value: unknown): DemoMarket { + if (typeof value !== "string") { + return "shelby"; + } + const normalized = value.trim().toLowerCase(); + return normalized === "mt_juliet" ? "mt_juliet" : "shelby"; +} + +function readDemoMarketFromRuntime(): DemoMarket { + let extraMarket: string | undefined; + try { + const Constants = require("expo-constants").default as { + expoConfig?: { extra?: { DEMO_MARKET?: string } }; + }; + extraMarket = Constants.expoConfig?.extra?.DEMO_MARKET; + } catch { + extraMarket = undefined; + } + // eslint-disable-next-line no-process-env + return normalizeDemoMarket(extraMarket ?? process.env.EXPO_PUBLIC_DEMO_MARKET); +} + +export function getDemoMarket(market = readDemoMarketFromRuntime()): DemoMarket { + return normalizeDemoMarket(market); +} + +export function getDemoMarketLabel(market = getDemoMarket()): string { + return DEMO_MARKET_LABELS[getDemoMarket(market)]; +} + +export function getDemoMarketDiscoveryLocation(market = getDemoMarket()): DemoMarketDiscoveryLocation { + return DEMO_MARKET_DISCOVERY_LOCATIONS[getDemoMarket(market)]; +} + +export function getDemoLoginAccounts(market = getDemoMarket()): DemoLoginAccount[] { + return DEMO_ACCOUNTS_BY_MARKET[getDemoMarket(market)]; +} diff --git a/apps/mobile/src/components/AppButton.tsx b/apps/mobile/src/components/AppButton.tsx new file mode 100644 index 0000000..a328052 --- /dev/null +++ b/apps/mobile/src/components/AppButton.tsx @@ -0,0 +1 @@ +export { AppButton, default } from "./ui/AppButton"; diff --git a/apps/mobile/src/components/AppCard.tsx b/apps/mobile/src/components/AppCard.tsx new file mode 100644 index 0000000..15331f2 --- /dev/null +++ b/apps/mobile/src/components/AppCard.tsx @@ -0,0 +1 @@ +export { AppCard, PressableCard, default } from "./ui/AppCard"; diff --git a/apps/mobile/src/components/AppScreen.tsx b/apps/mobile/src/components/AppScreen.tsx new file mode 100644 index 0000000..5b7af93 --- /dev/null +++ b/apps/mobile/src/components/AppScreen.tsx @@ -0,0 +1 @@ +export { AppScreen, default } from "./ui/AppScreen"; diff --git a/apps/mobile/src/components/AppointmentCard.tsx b/apps/mobile/src/components/AppointmentCard.tsx index b82367d..4445b90 100644 --- a/apps/mobile/src/components/AppointmentCard.tsx +++ b/apps/mobile/src/components/AppointmentCard.tsx @@ -1,11 +1,13 @@ import React from "react"; -import { Pressable, StyleSheet, View } from "react-native"; +import { StyleSheet, View } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useTheme } from "../theme/theme"; import type { AppointmentSummary } from "../types/booking"; import { Button } from "./ui/Button"; -import { Card } from "./ui/Card"; +import { MediaPlaceholder } from "./ui/MediaPlaceholder"; +import { PressableCard } from "./ui/Card"; +import { AppointmentStatusBadge, StatusBadge } from "./ui/StatusBadge"; import { Text } from "./ui/Text"; type Props = { @@ -13,237 +15,190 @@ type Props = { onPress?: (appointment: AppointmentSummary) => void; onClaim?: (appointment: AppointmentSummary) => void; claimable?: boolean; + claimDisabled?: boolean; + claimLoading?: boolean; helperText?: string; actionLabel?: string; emphasis?: "actionable" | "owned" | "neutral"; }; -const statusColors: Record = { - confirmed: "#1B998B", - requested: "#0F4C5C", - pending_payment: "#b45309", - payment_failed: "#dc2626", - cleaning: "#E6AF2E", - ready: "#2EC4B6", - out_for_delivery: "#2EC4B6", - delivered: "#1B998B", - completed: "#059669", - cancelled: "#9CA3AF", -}; +function getPaymentLabel(appointment: AppointmentSummary): { label: string; tone: "success" | "warning" | "danger" } | null { + if (appointment.payment_mode !== "service") { + return null; + } + if (appointment.payment_status === "succeeded") { + return { label: "Paid", tone: "success" }; + } + if (appointment.payment_status === "failed" || appointment.status === "payment_failed") { + return { label: "Payment failed", tone: "danger" }; + } + if (appointment.payment_status === "requires_action" || appointment.status === "pending_payment") { + return { label: appointment.payment_checkout_url ? "Complete payment" : "Payment pending", tone: "warning" }; + } + if (appointment.payment_status === "pending") { + return { label: "Payment pending", tone: "warning" }; + } + return null; +} export function AppointmentCard({ appointment, onPress, onClaim, claimable, + claimDisabled, + claimLoading, helperText, actionLabel, emphasis = "neutral", }: Props) { const theme = useTheme(); - const statusColor = statusColors[appointment.status] ?? theme.colors.mutedText; - const paymentStatus = appointment.payment_status ?? null; - const paymentLabel = (() => { - if (appointment.payment_mode !== "service") { - return null; - } - if (paymentStatus === "succeeded") { - return "Paid"; - } - if (paymentStatus === "failed" || appointment.status === "payment_failed") { - return "Payment failed"; - } - if (paymentStatus === "requires_action" || appointment.status === "pending_payment") { - return appointment.payment_checkout_url ? "Complete payment" : "Payment pending"; - } - if (paymentStatus === "pending") { - return "Payment pending"; - } - return null; - })(); - const paymentTone = (() => { - if (paymentLabel === "Paid") { - return { backgroundColor: "#ecfdf5", borderColor: "#86efac", color: "#166534" }; - } - if (paymentLabel === "Payment failed") { - return { backgroundColor: "#fef2f2", borderColor: "#fecaca", color: "#b91c1c" }; - } - return { backgroundColor: "#fffbeb", borderColor: "#fde68a", color: "#92400e" }; - })(); - const actionTone = { - actionable: { background: "#ecfdf5", border: "#86efac", text: "#166534", accent: "#0f766e" }, - owned: { background: "#eff6ff", border: "#93c5fd", text: "#1d4ed8", accent: "#1d4ed8" }, - neutral: { - background: "#f8fafc", - border: theme.colors.border, - text: theme.colors.mutedText, - accent: theme.colors.mutedText, - }, - }[emphasis]; + const payment = getPaymentLabel(appointment); + const area = [appointment.city, appointment.state].filter(Boolean).join(", ") || "Location pending"; + const appointmentDate = new Date(appointment.start_time); + const actionTone = emphasis === "actionable" ? "success" : emphasis === "owned" ? "primary" : "neutral"; + const accessibilityLabel = `Open appointment ${appointment.service_name ?? "appointment"} for ${ + appointment.customer_name ?? "customer" + }, status ${appointment.status.replace(/_/g, " ")}`; return ( - onPress?.(appointment)} style={({ pressed }) => [{ marginBottom: 14 }, pressed && { opacity: 0.96 }]}> - - - - - {actionLabel ? ( - - - {actionLabel} - - - ) : null} - - - {appointment.status.replace(/_/g, " ")} - - - {paymentLabel ? ( - - - {paymentLabel} - - - ) : null} - - - {appointment.service_name ?? "Appointment"} - - - {claimable ? "Review the job details, then claim when you are ready." : "Open the job for current progress, route details, and updates."} - - + onPress?.(appointment)} + variant={emphasis === "actionable" ? "elevated" : "marketplace"} + accessibilityLabel={accessibilityLabel} + style={[ + styles.card, + emphasis === "actionable" && { borderColor: `${theme.colors.success}44` }, + ]} + > + + + + + {appointment.service_name ?? "Appointment"} + + + {claimable ? "Review the job details, then claim when ready." : "Track pickup, care progress, payment, and delivery."} + + + - - - - - When - - {new Date(appointment.start_time).toLocaleString()} - - - - - - - Where - - {[appointment.city, appointment.state].filter(Boolean).join(", ") || "Location pending"} - - - - - - - Customer - - {appointment.customer_name} - - - + + {actionLabel ? : null} + {appointment.category_name ? : null} + + {payment ? : null} + + + + + + + + + {helperText ? ( + + + {helperText} + + ) : null} - {helperText ? ( - - - {helperText} - - - ) : null} + {claimable ? ( +