feat(merchant): extract merchant portal as first-class frontend with auth, Tailwind fixes, and Gitea CI
Some checks failed
Some checks failed
- Extract login/dashboard from billing module into core (matching admin pattern) - Add merchant auth API with path-isolated cookies (path=/merchants) - Add merchant base layout with sidebar/header partials and Alpine.js init - Add frontend detection and login redirect for MERCHANT type - Wire merchant token in shared api-client.js (get/clear) - Migrate billing templates to merchant base with dark mode support - Fix Tailwind: rename shop→storefront in sources and config - DRY Makefile tailwind targets with TAILWIND_FRONTENDS loop - Rebuild all Tailwind outputs (production minified) - Add Gitea Actions CI workflow (ruff, pytest, architecture, docs) - Add Gitea deployment documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
175
.gitea/workflows/ci.yml
Normal file
175
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Gitea Actions CI/CD Configuration
|
||||||
|
# ==================================
|
||||||
|
# Equivalent of the GitLab CI pipeline, using GitHub Actions-compatible syntax.
|
||||||
|
# Requires Gitea 1.19+ with Actions enabled.
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
env:
|
||||||
|
PYTHON_VERSION: "3.11"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ruff:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Run ruff
|
||||||
|
run: .venv/bin/ruff check .
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
pytest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:15
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: wizamart_test
|
||||||
|
POSTGRES_USER: test_user
|
||||||
|
POSTGRES_PASSWORD: test_password
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U test_user -d wizamart_test"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
env:
|
||||||
|
TEST_DATABASE_URL: "postgresql://test_user:test_password@localhost:5432/wizamart_test"
|
||||||
|
DATABASE_URL: "postgresql://test_user:test_password@localhost:5432/wizamart_test"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- name: Cache pip & venv
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/pip
|
||||||
|
.venv
|
||||||
|
key: ${{ runner.os }}-pip-${{ hashFiles('uv.lock', 'pyproject.toml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pip-
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: .venv/bin/python -m pytest tests/ -v --tb=short
|
||||||
|
|
||||||
|
architecture:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DATABASE_URL: "postgresql://dummy:dummy@localhost:5432/dummy"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Validate architecture
|
||||||
|
run: .venv/bin/python scripts/validate/validate_architecture.py
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Security (non-blocking)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
dependency-scanning:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- name: Install pip-audit
|
||||||
|
run: pip install pip-audit
|
||||||
|
|
||||||
|
- name: Run pip-audit
|
||||||
|
run: pip-audit --requirement requirements.txt || true
|
||||||
|
|
||||||
|
audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Run audit
|
||||||
|
run: .venv/bin/python scripts/validate/validate_audit.py
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Build (docs - only on push to master)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
docs:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
needs: [ruff, pytest, architecture]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
|
- name: Build docs
|
||||||
|
run: .venv/bin/mkdocs build
|
||||||
|
|
||||||
|
- name: Upload docs artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: docs-site
|
||||||
|
path: site/
|
||||||
22
Makefile
22
Makefile
@@ -399,26 +399,22 @@ tailwind-install:
|
|||||||
@mv tailwindcss-linux-x64 $(TAILWIND_CLI)
|
@mv tailwindcss-linux-x64 $(TAILWIND_CLI)
|
||||||
@echo "Tailwind CLI installed: $$($(TAILWIND_CLI) --help | head -1)"
|
@echo "Tailwind CLI installed: $$($(TAILWIND_CLI) --help | head -1)"
|
||||||
|
|
||||||
|
# All frontends that have a Tailwind build (static/<name>/css/tailwind.css)
|
||||||
|
TAILWIND_FRONTENDS := admin store storefront platform merchant
|
||||||
|
|
||||||
tailwind-dev:
|
tailwind-dev:
|
||||||
@echo "Building Tailwind CSS (development)..."
|
@echo "Building Tailwind CSS (development)..."
|
||||||
$(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css
|
@$(foreach fe,$(TAILWIND_FRONTENDS),$(TAILWIND_CLI) -i static/$(fe)/css/tailwind.css -o static/$(fe)/css/tailwind.output.css &&) true
|
||||||
$(TAILWIND_CLI) -i static/store/css/tailwind.css -o static/store/css/tailwind.output.css
|
@echo "Tailwind CSS built ($(TAILWIND_FRONTENDS))"
|
||||||
$(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css
|
|
||||||
$(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css
|
|
||||||
@echo "Tailwind CSS built (admin + store + shop + platform)"
|
|
||||||
|
|
||||||
tailwind-build:
|
tailwind-build:
|
||||||
@echo "Building Tailwind CSS (production - minified)..."
|
@echo "Building Tailwind CSS (production - minified)..."
|
||||||
$(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --minify
|
@$(foreach fe,$(TAILWIND_FRONTENDS),$(TAILWIND_CLI) -i static/$(fe)/css/tailwind.css -o static/$(fe)/css/tailwind.output.css --minify &&) true
|
||||||
$(TAILWIND_CLI) -i static/store/css/tailwind.css -o static/store/css/tailwind.output.css --minify
|
|
||||||
$(TAILWIND_CLI) -i static/shop/css/tailwind.css -o static/shop/css/tailwind.output.css --minify
|
|
||||||
$(TAILWIND_CLI) -i static/platform/css/tailwind.css -o static/platform/css/tailwind.output.css --minify
|
|
||||||
@echo "Tailwind CSS built and minified for production"
|
@echo "Tailwind CSS built and minified for production"
|
||||||
|
|
||||||
tailwind-watch:
|
tailwind-watch:
|
||||||
@echo "Watching Tailwind CSS for changes..."
|
@echo "Watching Tailwind CSS for changes ($(fe))..."
|
||||||
@echo "Note: This watches admin CSS only. Run in separate terminal."
|
$(TAILWIND_CLI) -i static/$(fe)/css/tailwind.css -o static/$(fe)/css/tailwind.output.css --watch
|
||||||
$(TAILWIND_CLI) -i static/admin/css/tailwind.css -o static/admin/css/tailwind.output.css --watch
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# CELERY / TASK QUEUE
|
# CELERY / TASK QUEUE
|
||||||
@@ -593,7 +589,7 @@ help:
|
|||||||
@echo " tailwind-install - Install Tailwind standalone CLI"
|
@echo " tailwind-install - Install Tailwind standalone CLI"
|
||||||
@echo " tailwind-dev - Build Tailwind CSS (development)"
|
@echo " tailwind-dev - Build Tailwind CSS (development)"
|
||||||
@echo " tailwind-build - Build Tailwind CSS (production, minified)"
|
@echo " tailwind-build - Build Tailwind CSS (production, minified)"
|
||||||
@echo " tailwind-watch - Watch and rebuild on changes"
|
@echo " tailwind-watch fe=X - Watch and rebuild on changes (specify frontend)"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=== CELERY / TASK QUEUE ==="
|
@echo "=== CELERY / TASK QUEUE ==="
|
||||||
@echo " celery-worker - Start Celery worker"
|
@echo " celery-worker - Start Celery worker"
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ class FrontendDetector:
|
|||||||
"/api/v1/shop", # Legacy support
|
"/api/v1/shop", # Legacy support
|
||||||
"/stores/", # Path-based store access
|
"/stores/", # Path-based store access
|
||||||
)
|
)
|
||||||
|
MERCHANT_PATH_PREFIXES = ("/merchants", "/api/v1/merchants")
|
||||||
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
|
PLATFORM_PATH_PREFIXES = ("/api/v1/platform",)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -94,6 +95,10 @@ class FrontendDetector:
|
|||||||
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from path")
|
logger.debug("[FRONTEND_DETECTOR] Detected ADMIN from path")
|
||||||
return FrontendType.ADMIN
|
return FrontendType.ADMIN
|
||||||
|
|
||||||
|
if cls._matches_any(path, cls.MERCHANT_PATH_PREFIXES):
|
||||||
|
logger.debug("[FRONTEND_DETECTOR] Detected MERCHANT from path")
|
||||||
|
return FrontendType.MERCHANT
|
||||||
|
|
||||||
# Check storefront BEFORE store since /api/v1/storefront starts with /api/v1/store
|
# Check storefront BEFORE store since /api/v1/storefront starts with /api/v1/store
|
||||||
if cls._matches_any(path, cls.STOREFRONT_PATH_PREFIXES):
|
if cls._matches_any(path, cls.STOREFRONT_PATH_PREFIXES):
|
||||||
logger.debug("[FRONTEND_DETECTOR] Detected STOREFRONT from path")
|
logger.debug("[FRONTEND_DETECTOR] Detected STOREFRONT from path")
|
||||||
|
|||||||
@@ -393,6 +393,9 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
|||||||
if frontend_type == FrontendType.ADMIN:
|
if frontend_type == FrontendType.ADMIN:
|
||||||
logger.debug("Redirecting to /admin/login")
|
logger.debug("Redirecting to /admin/login")
|
||||||
return RedirectResponse(url="/admin/login", status_code=302)
|
return RedirectResponse(url="/admin/login", status_code=302)
|
||||||
|
if frontend_type == FrontendType.MERCHANT:
|
||||||
|
logger.debug("Redirecting to /merchants/login")
|
||||||
|
return RedirectResponse(url="/merchants/login", status_code=302)
|
||||||
if frontend_type == FrontendType.STORE:
|
if frontend_type == FrontendType.STORE:
|
||||||
# Extract store code from the request path
|
# Extract store code from the request path
|
||||||
# Path format: /store/{store_code}/...
|
# Path format: /store/{store_code}/...
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
Merchant Billing Page Routes (HTML rendering).
|
Merchant Billing Page Routes (HTML rendering).
|
||||||
|
|
||||||
Page routes for the merchant billing portal:
|
Page routes for the merchant billing portal:
|
||||||
- Dashboard (overview of stores, subscriptions)
|
|
||||||
- Subscriptions list
|
- Subscriptions list
|
||||||
- Subscription detail per platform
|
- Subscription detail per platform
|
||||||
- Billing history / invoices
|
- Billing history / invoices
|
||||||
- Login page
|
|
||||||
|
|
||||||
Authentication: merchant_token cookie or Authorization header.
|
Authentication: merchant_token cookie or Authorization header.
|
||||||
Login page uses optional auth to check if already logged in.
|
|
||||||
|
Login and dashboard routes have moved to core module
|
||||||
|
(app/modules/core/routes/pages/merchant.py) to match the admin pattern.
|
||||||
|
|
||||||
Auto-discovered by the route system (merchant.py in routes/pages/ triggers
|
Auto-discovered by the route system (merchant.py in routes/pages/ triggers
|
||||||
registration under /merchants/billing/*).
|
registration under /merchants/billing/*).
|
||||||
@@ -20,10 +20,7 @@ from fastapi import APIRouter, Depends, Path, Request
|
|||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import (
|
from app.api.deps import get_current_merchant_from_cookie_or_header
|
||||||
get_current_merchant_from_cookie_or_header,
|
|
||||||
get_current_merchant_optional,
|
|
||||||
)
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.modules.core.utils.page_context import get_context_for_frontend
|
from app.modules.core.utils.page_context import get_context_for_frontend
|
||||||
from app.modules.enums import FrontendType
|
from app.modules.enums import FrontendType
|
||||||
@@ -53,15 +50,6 @@ def _get_merchant_context(
|
|||||||
|
|
||||||
Uses the module-driven context builder with FrontendType.MERCHANT,
|
Uses the module-driven context builder with FrontendType.MERCHANT,
|
||||||
and adds the authenticated user to the context.
|
and adds the authenticated user to the context.
|
||||||
|
|
||||||
Args:
|
|
||||||
request: FastAPI request
|
|
||||||
db: Database session
|
|
||||||
current_user: Authenticated merchant user context
|
|
||||||
**extra_context: Additional template variables
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict of context variables for template rendering
|
|
||||||
"""
|
"""
|
||||||
return get_context_for_frontend(
|
return get_context_for_frontend(
|
||||||
FrontendType.MERCHANT,
|
FrontendType.MERCHANT,
|
||||||
@@ -73,26 +61,14 @@ def _get_merchant_context(
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# DASHBOARD
|
# BILLING ROOT
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
|
||||||
async def merchant_dashboard_page(
|
async def merchant_billing_root():
|
||||||
request: Request,
|
"""Redirect /merchants/billing/ to subscriptions page."""
|
||||||
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
return RedirectResponse(url="/merchants/billing/subscriptions", status_code=302)
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Render merchant dashboard page.
|
|
||||||
|
|
||||||
Shows an overview of the merchant's stores and subscriptions.
|
|
||||||
"""
|
|
||||||
context = _get_merchant_context(request, db, current_user)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"billing/merchant/dashboard.html",
|
|
||||||
context,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -164,35 +140,3 @@ async def merchant_billing_history_page(
|
|||||||
"billing/merchant/billing-history.html",
|
"billing/merchant/billing-history.html",
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# LOGIN
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
|
||||||
async def merchant_login_page(
|
|
||||||
request: Request,
|
|
||||||
current_user: UserContext | None = Depends(get_current_merchant_optional),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Render merchant login page.
|
|
||||||
|
|
||||||
If the user is already authenticated as a merchant owner,
|
|
||||||
redirects to the merchant dashboard.
|
|
||||||
"""
|
|
||||||
# Redirect to dashboard if already logged in
|
|
||||||
if current_user is not None:
|
|
||||||
return RedirectResponse(url="/merchants/billing/", status_code=302)
|
|
||||||
|
|
||||||
context = get_context_for_frontend(
|
|
||||||
FrontendType.MERCHANT,
|
|
||||||
request,
|
|
||||||
db,
|
|
||||||
)
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"billing/merchant/login.html",
|
|
||||||
context,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -7,22 +7,22 @@
|
|||||||
<div x-data="merchantBillingHistory()">
|
<div x-data="merchantBillingHistory()">
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8 mt-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Billing History</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Billing History</h2>
|
||||||
<p class="mt-1 text-gray-500">View your invoices and payment history.</p>
|
<p class="mt-1 text-gray-500 dark:text-gray-400">View your invoices and payment history.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
<p class="text-sm text-red-800" x-text="error"></p>
|
<p class="text-sm text-red-800 dark:text-red-400" x-text="error"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invoices Table -->
|
<!-- Invoices Table -->
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full whitespace-nowrap">
|
<table class="w-full whitespace-nowrap">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase bg-gray-50 border-b border-gray-200">
|
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-700">
|
||||||
<th class="px-6 py-3">Date</th>
|
<th class="px-6 py-3">Date</th>
|
||||||
<th class="px-6 py-3">Invoice #</th>
|
<th class="px-6 py-3">Invoice #</th>
|
||||||
<th class="px-6 py-3 text-right">Amount</th>
|
<th class="px-6 py-3 text-right">Amount</th>
|
||||||
@@ -30,11 +30,11 @@
|
|||||||
<th class="px-6 py-3 text-right">Actions</th>
|
<th class="px-6 py-3 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<template x-if="loading">
|
<template x-if="loading">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
|
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<svg class="inline w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
<svg class="inline w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<template x-if="!loading && invoices.length === 0">
|
<template x-if="!loading && invoices.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
|
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
No invoices found.
|
No invoices found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
<!-- Rows -->
|
<!-- Rows -->
|
||||||
<template x-for="invoice in invoices" :key="invoice.id">
|
<template x-for="invoice in invoices" :key="invoice.id">
|
||||||
<tr class="text-gray-700 hover:bg-gray-50 transition-colors">
|
<tr class="text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
<td class="px-6 py-4 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
|
<td class="px-6 py-4 text-sm" x-text="formatDate(invoice.invoice_date)"></td>
|
||||||
<td class="px-6 py-4 text-sm font-mono" x-text="invoice.invoice_number || '-'"></td>
|
<td class="px-6 py-4 text-sm font-mono" x-text="invoice.invoice_number || '-'"></td>
|
||||||
<td class="px-6 py-4 text-right">
|
<td class="px-6 py-4 text-right">
|
||||||
@@ -64,11 +64,11 @@
|
|||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-green-100 text-green-800': invoice.status === 'paid',
|
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': invoice.status === 'paid',
|
||||||
'bg-yellow-100 text-yellow-800': invoice.status === 'open',
|
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': invoice.status === 'open',
|
||||||
'bg-gray-100 text-gray-600': invoice.status === 'draft',
|
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': invoice.status === 'draft',
|
||||||
'bg-red-100 text-red-800': invoice.status === 'uncollectible',
|
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': invoice.status === 'uncollectible',
|
||||||
'bg-gray-100 text-gray-500': invoice.status === 'void'
|
'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-500': invoice.status === 'void'
|
||||||
}"
|
}"
|
||||||
x-text="invoice.status.toUpperCase()"></span>
|
x-text="invoice.status.toUpperCase()"></span>
|
||||||
</td>
|
</td>
|
||||||
@@ -77,21 +77,17 @@
|
|||||||
<a x-show="invoice.hosted_invoice_url"
|
<a x-show="invoice.hosted_invoice_url"
|
||||||
:href="invoice.hosted_invoice_url"
|
:href="invoice.hosted_invoice_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||||
title="View Invoice">
|
title="View Invoice">
|
||||||
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span x-html="$icon('external-link', 'w-3.5 h-3.5 mr-1')"></span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
|
|
||||||
</svg>
|
|
||||||
View
|
View
|
||||||
</a>
|
</a>
|
||||||
<a x-show="invoice.invoice_pdf_url"
|
<a x-show="invoice.invoice_pdf_url"
|
||||||
:href="invoice.invoice_pdf_url"
|
:href="invoice.invoice_pdf_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors"
|
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/20 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors"
|
||||||
title="Download PDF">
|
title="Download PDF">
|
||||||
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span x-html="$icon('download', 'w-3.5 h-3.5 mr-1')"></span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
|
||||||
</svg>
|
|
||||||
PDF
|
PDF
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,28 +114,9 @@ function merchantBillingHistory() {
|
|||||||
this.loadInvoices();
|
this.loadInvoices();
|
||||||
},
|
},
|
||||||
|
|
||||||
getToken() {
|
|
||||||
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
|
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadInvoices() {
|
async loadInvoices() {
|
||||||
const token = this.getToken();
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/merchants/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/v1/merchants/billing/invoices', {
|
const data = await apiClient.get('/merchants/billing/invoices');
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
if (resp.status === 401) {
|
|
||||||
window.location.href = '/merchants/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!resp.ok) throw new Error('Failed to load invoices');
|
|
||||||
const data = await resp.json();
|
|
||||||
this.invoices = data.invoices || data.items || [];
|
this.invoices = data.invoices || data.items || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading invoices:', err);
|
console.error('Error loading invoices:', err);
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
{# app/modules/billing/templates/billing/merchant/dashboard.html #}
|
|
||||||
{% extends "merchant/base.html" %}
|
|
||||||
|
|
||||||
{% block title %}Dashboard{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div x-data="merchantDashboard()">
|
|
||||||
|
|
||||||
<!-- Welcome -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Welcome back<span x-show="merchantName">, <span x-text="merchantName"></span></span></h2>
|
|
||||||
<p class="mt-1 text-gray-500">Here is an overview of your account.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick Stats -->
|
|
||||||
<div class="grid gap-6 mb-8 md:grid-cols-3">
|
|
||||||
<!-- Active Subscriptions -->
|
|
||||||
<div class="flex items-center p-6 bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="p-3 mr-4 text-indigo-600 bg-indigo-100 rounded-full">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-500">Active Subscriptions</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900" x-text="stats.active_subscriptions">--</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Total Stores -->
|
|
||||||
<div class="flex items-center p-6 bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="p-3 mr-4 text-green-600 bg-green-100 rounded-full">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-500">Total Stores</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900" x-text="stats.total_stores">--</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Plan -->
|
|
||||||
<div class="flex items-center p-6 bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="p-3 mr-4 text-purple-600 bg-purple-100 rounded-full">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-gray-500">Current Plan</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900" x-text="stats.current_plan || '--'">--</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subscription Overview -->
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Subscription Overview</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-6">
|
|
||||||
<!-- Loading -->
|
|
||||||
<div x-show="loading" class="text-center py-8 text-gray-500">
|
|
||||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Subscriptions list -->
|
|
||||||
<div x-show="!loading && subscriptions.length > 0" class="space-y-4">
|
|
||||||
<template x-for="sub in subscriptions" :key="sub.id">
|
|
||||||
<div class="flex items-center justify-between p-4 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold text-gray-900" x-text="sub.platform_name || 'Subscription'"></p>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
<span x-text="sub.tier" class="capitalize"></span> ·
|
|
||||||
Renews <span x-text="formatDate(sub.period_end)"></span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="px-3 py-1 text-xs font-semibold rounded-full"
|
|
||||||
:class="{
|
|
||||||
'bg-green-100 text-green-800': sub.status === 'active',
|
|
||||||
'bg-blue-100 text-blue-800': sub.status === 'trial',
|
|
||||||
'bg-yellow-100 text-yellow-800': sub.status === 'past_due',
|
|
||||||
'bg-red-100 text-red-800': sub.status === 'cancelled'
|
|
||||||
}"
|
|
||||||
x-text="sub.status.replace('_', ' ')"></span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div x-show="!loading && subscriptions.length === 0" class="text-center py-8">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-500">No active subscriptions.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block extra_scripts %}
|
|
||||||
<script>
|
|
||||||
function merchantDashboard() {
|
|
||||||
return {
|
|
||||||
loading: true,
|
|
||||||
merchantName: '',
|
|
||||||
stats: {
|
|
||||||
active_subscriptions: '--',
|
|
||||||
total_stores: '--',
|
|
||||||
current_plan: '--'
|
|
||||||
},
|
|
||||||
subscriptions: [],
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// Get merchant name from parent component
|
|
||||||
const token = this.getToken();
|
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
||||||
this.merchantName = payload.merchant_name || '';
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
this.loadDashboard();
|
|
||||||
},
|
|
||||||
|
|
||||||
getToken() {
|
|
||||||
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
|
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadDashboard() {
|
|
||||||
const token = this.getToken();
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/merchants/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/v1/merchants/billing/subscriptions', {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
if (resp.status === 401) {
|
|
||||||
window.location.href = '/merchants/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
this.subscriptions = data.subscriptions || data.items || [];
|
|
||||||
|
|
||||||
const active = this.subscriptions.filter(s => s.status === 'active' || s.status === 'trial');
|
|
||||||
this.stats.active_subscriptions = active.length;
|
|
||||||
this.stats.total_stores = this.subscriptions.length;
|
|
||||||
this.stats.current_plan = active.length > 0
|
|
||||||
? active[0].tier.charAt(0).toUpperCase() + active[0].tier.slice(1)
|
|
||||||
: 'None';
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load dashboard:', error);
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
formatDate(dateStr) {
|
|
||||||
if (!dateStr) return '-';
|
|
||||||
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
{# app/modules/billing/templates/billing/merchant/login.html #}
|
|
||||||
{# Standalone login page - does NOT extend merchant/base.html #}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Merchant Login - Wizamart</title>
|
|
||||||
|
|
||||||
<!-- Fonts -->
|
|
||||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
|
||||||
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" />
|
|
||||||
|
|
||||||
<style>
|
|
||||||
[x-cloak] { display: none !important; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body class="bg-gray-50 font-sans" x-cloak>
|
|
||||||
<div class="flex items-center justify-center min-h-screen px-4" x-data="merchantLogin()">
|
|
||||||
<div class="w-full max-w-md">
|
|
||||||
|
|
||||||
<!-- Logo -->
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<div class="inline-flex items-center justify-center w-14 h-14 bg-indigo-600 rounded-xl mb-4">
|
|
||||||
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Merchant Portal</h1>
|
|
||||||
<p class="mt-1 text-gray-500">Sign in to manage your account</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Login Card -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
|
||||||
|
|
||||||
<!-- Error message -->
|
|
||||||
<div x-show="error" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<p class="text-sm text-red-800" x-text="error"></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleLogin()">
|
|
||||||
<!-- Email/Username -->
|
|
||||||
<div class="mb-5">
|
|
||||||
<label for="login_email" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Email or Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="login_email"
|
|
||||||
type="text"
|
|
||||||
x-model="email"
|
|
||||||
required
|
|
||||||
autocomplete="username"
|
|
||||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
|
||||||
placeholder="you@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Password -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<label for="login_password" class="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="login_password"
|
|
||||||
type="password"
|
|
||||||
x-model="password"
|
|
||||||
required
|
|
||||||
autocomplete="current-password"
|
|
||||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg text-gray-900 placeholder-gray-400 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
|
||||||
placeholder="Enter your password"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Submit -->
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
:disabled="loading || !email || !password"
|
|
||||||
class="w-full px-4 py-2.5 text-sm font-semibold text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<span x-show="!loading">Sign In</span>
|
|
||||||
<span x-show="loading" class="inline-flex items-center">
|
|
||||||
<svg class="w-4 h-4 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
||||||
</svg>
|
|
||||||
Signing in...
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<p class="mt-6 text-center text-sm text-gray-400">
|
|
||||||
© 2026 Wizamart. All rights reserved.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Alpine.js -->
|
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function merchantLogin() {
|
|
||||||
return {
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
|
|
||||||
init() {
|
|
||||||
// If already logged in, redirect to dashboard
|
|
||||||
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
|
|
||||||
if (match && match[1]) {
|
|
||||||
window.location.href = '/merchants/billing/';
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async handleLogin() {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await fetch('/api/v1/merchants/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: this.email,
|
|
||||||
password: this.password
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await resp.json();
|
|
||||||
|
|
||||||
if (!resp.ok) {
|
|
||||||
this.error = data.detail || 'Invalid credentials. Please try again.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set merchant_token cookie (expires in 24 hours)
|
|
||||||
const token = data.access_token || data.token;
|
|
||||||
if (token) {
|
|
||||||
const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString();
|
|
||||||
document.cookie = `merchant_token=${encodeURIComponent(token)}; path=/; expires=${expires}; SameSite=Lax`;
|
|
||||||
window.location.href = '/merchants/billing/';
|
|
||||||
} else {
|
|
||||||
this.error = 'Login succeeded but no token was returned.';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Login error:', err);
|
|
||||||
this.error = 'Unable to connect to the server. Please try again.';
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -7,18 +7,16 @@
|
|||||||
<div x-data="merchantSubscriptionDetail()">
|
<div x-data="merchantSubscriptionDetail()">
|
||||||
|
|
||||||
<!-- Back link and header -->
|
<!-- Back link and header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8 mt-6">
|
||||||
<a href="/merchants/billing/subscriptions" class="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-800 mb-4">
|
<a href="/merchants/billing/subscriptions" class="inline-flex items-center text-sm text-purple-600 dark:text-purple-400 hover:text-purple-800 dark:hover:text-purple-300 mb-4">
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span x-html="$icon('chevron-left', 'w-4 h-4 mr-1')"></span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
|
||||||
</svg>
|
|
||||||
Back to Subscriptions
|
Back to Subscriptions
|
||||||
</a>
|
</a>
|
||||||
<h2 class="text-2xl font-bold text-gray-900">Subscription Details</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Subscription Details</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div x-show="loading" class="text-center py-12 text-gray-500">
|
<div x-show="loading" class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
@@ -27,78 +25,78 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
<p class="text-sm text-red-800" x-text="error"></p>
|
<p class="text-sm text-red-800 dark:text-red-400" x-text="error"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- Success Message -->
|
||||||
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
<div x-show="successMessage" x-cloak class="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
<p class="text-sm text-green-800" x-text="successMessage"></p>
|
<p class="text-sm text-green-800 dark:text-green-400" x-text="successMessage"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subscription Info -->
|
<!-- Subscription Info -->
|
||||||
<div x-show="!loading && subscription" class="space-y-6">
|
<div x-show="!loading && subscription" class="space-y-6">
|
||||||
|
|
||||||
<!-- Main Details Card -->
|
<!-- Main Details Card -->
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<div class="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-semibold text-gray-900" x-text="subscription?.platform_name || 'Subscription'"></h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="subscription?.platform_name || 'Subscription'"></h3>
|
||||||
<span class="px-3 py-1 text-sm font-semibold rounded-full"
|
<span class="px-3 py-1 text-sm font-semibold rounded-full"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-green-100 text-green-800': subscription?.status === 'active',
|
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': subscription?.status === 'active',
|
||||||
'bg-blue-100 text-blue-800': subscription?.status === 'trial',
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': subscription?.status === 'trial',
|
||||||
'bg-yellow-100 text-yellow-800': subscription?.status === 'past_due',
|
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': subscription?.status === 'past_due',
|
||||||
'bg-red-100 text-red-800': subscription?.status === 'cancelled',
|
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': subscription?.status === 'cancelled',
|
||||||
'bg-gray-100 text-gray-600': subscription?.status === 'expired'
|
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': subscription?.status === 'expired'
|
||||||
}"
|
}"
|
||||||
x-text="subscription?.status?.replace('_', ' ').toUpperCase()"></span>
|
x-text="subscription?.status?.replace('_', ' ').toUpperCase()"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<dl class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<dl class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Tier</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Tier</dt>
|
||||||
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="capitalize(subscription?.tier)"></dd>
|
<dd class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="capitalize(subscription?.tier)"></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Billing Period</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Billing Period</dt>
|
||||||
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="subscription?.is_annual ? 'Annual' : 'Monthly'"></dd>
|
<dd class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="subscription?.is_annual ? 'Annual' : 'Monthly'"></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Period End</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Period End</dt>
|
||||||
<dd class="mt-1 text-lg font-semibold text-gray-900" x-text="formatDate(subscription?.period_end)"></dd>
|
<dd class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100" x-text="formatDate(subscription?.period_end)"></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Platform</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Platform</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.platform_name || '-'"></dd>
|
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="subscription?.platform_name || '-'"></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Created</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-700" x-text="formatDate(subscription?.created_at)"></dd>
|
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="formatDate(subscription?.created_at)"></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-gray-500">Auto Renew</dt>
|
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Auto Renew</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-700" x-text="subscription?.auto_renew !== false ? 'Yes' : 'No'"></dd>
|
<dd class="mt-1 text-sm text-gray-700 dark:text-gray-300" x-text="subscription?.auto_renew !== false ? 'Yes' : 'No'"></dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Feature Limits Card -->
|
<!-- Feature Limits Card -->
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Plan Features</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Plan Features</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
<template x-for="fl in (subscription?.tier?.feature_limits || subscription?.feature_limits || [])" :key="fl.feature_code">
|
<template x-for="fl in (subscription?.tier?.feature_limits || subscription?.feature_limits || [])" :key="fl.feature_code">
|
||||||
<div class="p-4 bg-gray-50 rounded-lg">
|
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||||
<p class="text-sm text-gray-500" x-text="fl.feature_code.replace(/_/g, ' ')"></p>
|
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="fl.feature_code.replace(/_/g, ' ')"></p>
|
||||||
<p class="text-xl font-bold text-gray-900" x-text="fl.limit_value || 'Unlimited'"></p>
|
<p class="text-xl font-bold text-gray-900 dark:text-gray-100" x-text="fl.limit_value || 'Unlimited'"></p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!(subscription?.tier?.feature_limits || subscription?.feature_limits || []).length">
|
<template x-if="!(subscription?.tier?.feature_limits || subscription?.feature_limits || []).length">
|
||||||
<div class="p-4 bg-gray-50 rounded-lg sm:col-span-3">
|
<div class="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg sm:col-span-3">
|
||||||
<p class="text-sm text-gray-500 text-center">No feature limits configured for this tier</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">No feature limits configured for this tier</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,24 +105,24 @@
|
|||||||
|
|
||||||
<!-- Change Plan -->
|
<!-- Change Plan -->
|
||||||
<div x-show="availableTiers.length > 0 && (subscription?.status === 'active' || subscription?.status === 'trial')"
|
<div x-show="availableTiers.length > 0 && (subscription?.status === 'active' || subscription?.status === 'trial')"
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200">
|
class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-gray-900">Change Plan</h3>
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Change Plan</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div class="p-6 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<template x-for="t in availableTiers" :key="t.code">
|
<template x-for="t in availableTiers" :key="t.code">
|
||||||
<div class="p-4 border rounded-lg transition-colors"
|
<div class="p-4 border rounded-lg transition-colors"
|
||||||
:class="t.is_current ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:border-gray-300'">
|
:class="t.is_current ? 'border-purple-500 dark:border-purple-400 bg-purple-50 dark:bg-purple-900/20' : 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'">
|
||||||
<h4 class="font-semibold text-gray-900" x-text="t.name"></h4>
|
<h4 class="font-semibold text-gray-900 dark:text-gray-100" x-text="t.name"></h4>
|
||||||
<p class="text-sm text-gray-500 mt-1" x-text="formatCurrency(t.price_monthly_cents) + '/mo'"></p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1" x-text="formatCurrency(t.price_monthly_cents) + '/mo'"></p>
|
||||||
<template x-if="t.is_current">
|
<template x-if="t.is_current">
|
||||||
<span class="inline-block mt-3 px-3 py-1 text-xs font-semibold text-indigo-700 bg-indigo-100 rounded-full">Current Plan</span>
|
<span class="inline-block mt-3 px-3 py-1 text-xs font-semibold text-purple-700 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">Current Plan</span>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!t.is_current">
|
<template x-if="!t.is_current">
|
||||||
<button @click="changeTier(t.code)"
|
<button @click="changeTier(t.code)"
|
||||||
:disabled="changingTier"
|
:disabled="changingTier"
|
||||||
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors disabled:opacity-50"
|
class="mt-3 inline-flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors disabled:opacity-50"
|
||||||
:class="t.can_upgrade ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-gray-600 hover:bg-gray-700'"
|
:class="t.can_upgrade ? 'bg-purple-600 hover:bg-purple-700' : 'bg-gray-600 hover:bg-gray-700'"
|
||||||
x-text="changingTier ? 'Processing...' : (t.can_upgrade ? 'Upgrade' : 'Downgrade')">
|
x-text="changingTier ? 'Processing...' : (t.can_upgrade ? 'Upgrade' : 'Downgrade')">
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -152,11 +150,6 @@ function merchantSubscriptionDetail() {
|
|||||||
this.loadSubscription();
|
this.loadSubscription();
|
||||||
},
|
},
|
||||||
|
|
||||||
getToken() {
|
|
||||||
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
|
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
getPlatformId() {
|
getPlatformId() {
|
||||||
// Extract platform_id from URL: /merchants/billing/subscriptions/{platform_id}
|
// Extract platform_id from URL: /merchants/billing/subscriptions/{platform_id}
|
||||||
const parts = window.location.pathname.split('/');
|
const parts = window.location.pathname.split('/');
|
||||||
@@ -164,23 +157,9 @@ function merchantSubscriptionDetail() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadSubscription() {
|
async loadSubscription() {
|
||||||
const token = this.getToken();
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/merchants/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const platformId = this.getPlatformId();
|
const platformId = this.getPlatformId();
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}`, {
|
const data = await apiClient.get(`/merchants/billing/subscriptions/${platformId}`);
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
if (resp.status === 401) {
|
|
||||||
window.location.href = '/merchants/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!resp.ok) throw new Error('Failed to load subscription');
|
|
||||||
const data = await resp.json();
|
|
||||||
this.subscription = data.subscription || data;
|
this.subscription = data.subscription || data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error:', err);
|
console.error('Error:', err);
|
||||||
@@ -194,15 +173,8 @@ function merchantSubscriptionDetail() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async loadAvailableTiers(platformId) {
|
async loadAvailableTiers(platformId) {
|
||||||
const token = this.getToken();
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}/tiers`, {
|
const data = await apiClient.get(`/merchants/billing/subscriptions/${platformId}/tiers`);
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
this.availableTiers = data.tiers || [];
|
this.availableTiers = data.tiers || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load tiers:', err);
|
console.error('Failed to load tiers:', err);
|
||||||
@@ -216,23 +188,13 @@ function merchantSubscriptionDetail() {
|
|||||||
this.error = null;
|
this.error = null;
|
||||||
this.successMessage = null;
|
this.successMessage = null;
|
||||||
|
|
||||||
const token = this.getToken();
|
|
||||||
const platformId = this.getPlatformId();
|
const platformId = this.getPlatformId();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/v1/merchants/billing/subscriptions/${platformId}/change-tier`, {
|
const result = await apiClient.post(`/merchants/billing/subscriptions/${platformId}/change-tier`, {
|
||||||
method: 'POST',
|
tier_code: tierCode,
|
||||||
headers: {
|
is_annual: this.subscription?.is_annual || false
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ tier_code: tierCode, is_annual: this.subscription?.is_annual || false })
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
|
||||||
const data = await resp.json();
|
|
||||||
throw new Error(data.detail || 'Failed to change tier');
|
|
||||||
}
|
|
||||||
const result = await resp.json();
|
|
||||||
this.successMessage = result.message || 'Plan changed successfully.';
|
this.successMessage = result.message || 'Plan changed successfully.';
|
||||||
|
|
||||||
// Reload data
|
// Reload data
|
||||||
|
|||||||
@@ -7,22 +7,22 @@
|
|||||||
<div x-data="merchantSubscriptions()">
|
<div x-data="merchantSubscriptions()">
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8 mt-6">
|
||||||
<h2 class="text-2xl font-bold text-gray-900">My Subscriptions</h2>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">My Subscriptions</h2>
|
||||||
<p class="mt-1 text-gray-500">Manage your platform subscriptions and plans.</p>
|
<p class="mt-1 text-gray-500 dark:text-gray-400">Manage your platform subscriptions and plans.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
<div x-show="error" x-cloak class="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
<p class="text-sm text-red-800" x-text="error"></p>
|
<p class="text-sm text-red-800 dark:text-red-400" x-text="error"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subscriptions Table -->
|
<!-- Subscriptions Table -->
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full whitespace-nowrap">
|
<table class="w-full whitespace-nowrap">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase bg-gray-50 border-b border-gray-200">
|
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 dark:text-gray-400 uppercase bg-gray-50 dark:bg-gray-700 border-b border-gray-200 dark:border-gray-700">
|
||||||
<th class="px-6 py-3">Platform</th>
|
<th class="px-6 py-3">Platform</th>
|
||||||
<th class="px-6 py-3">Tier</th>
|
<th class="px-6 py-3">Tier</th>
|
||||||
<th class="px-6 py-3">Status</th>
|
<th class="px-6 py-3">Status</th>
|
||||||
@@ -30,11 +30,11 @@
|
|||||||
<th class="px-6 py-3 text-right">Actions</th>
|
<th class="px-6 py-3 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-200">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<template x-if="loading">
|
<template x-if="loading">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
|
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<svg class="inline w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
<svg class="inline w-5 h-5 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
<!-- Empty -->
|
<!-- Empty -->
|
||||||
<template x-if="!loading && subscriptions.length === 0">
|
<template x-if="!loading && subscriptions.length === 0">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
|
<td colspan="5" class="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
No subscriptions found.
|
No subscriptions found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -55,35 +55,35 @@
|
|||||||
|
|
||||||
<!-- Rows -->
|
<!-- Rows -->
|
||||||
<template x-for="sub in subscriptions" :key="sub.id">
|
<template x-for="sub in subscriptions" :key="sub.id">
|
||||||
<tr class="text-gray-700 hover:bg-gray-50 transition-colors">
|
<tr class="text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<p class="font-semibold text-gray-900" x-text="sub.platform_name"></p>
|
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.platform_name"></p>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-indigo-100 text-indigo-800': sub.tier === 'essential',
|
'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400': sub.tier === 'essential',
|
||||||
'bg-blue-100 text-blue-800': sub.tier === 'professional',
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': sub.tier === 'professional',
|
||||||
'bg-green-100 text-green-800': sub.tier === 'business',
|
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': sub.tier === 'business',
|
||||||
'bg-yellow-100 text-yellow-800': sub.tier === 'enterprise'
|
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': sub.tier === 'enterprise'
|
||||||
}"
|
}"
|
||||||
x-text="sub.tier.charAt(0).toUpperCase() + sub.tier.slice(1)"></span>
|
x-text="sub.tier.charAt(0).toUpperCase() + sub.tier.slice(1)"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
<span class="px-2.5 py-1 text-xs font-semibold rounded-full"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-green-100 text-green-800': sub.status === 'active',
|
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': sub.status === 'active',
|
||||||
'bg-blue-100 text-blue-800': sub.status === 'trial',
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': sub.status === 'trial',
|
||||||
'bg-yellow-100 text-yellow-800': sub.status === 'past_due',
|
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': sub.status === 'past_due',
|
||||||
'bg-red-100 text-red-800': sub.status === 'cancelled',
|
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': sub.status === 'cancelled',
|
||||||
'bg-gray-100 text-gray-600': sub.status === 'expired'
|
'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400': sub.status === 'expired'
|
||||||
}"
|
}"
|
||||||
x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
|
x-text="sub.status.replace('_', ' ').toUpperCase()"></span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm" x-text="formatDate(sub.period_end)"></td>
|
<td class="px-6 py-4 text-sm" x-text="formatDate(sub.period_end)"></td>
|
||||||
<td class="px-6 py-4 text-right">
|
<td class="px-6 py-4 text-right">
|
||||||
<a :href="'/merchants/billing/subscriptions/' + sub.platform_id"
|
<a :href="'/merchants/billing/subscriptions/' + sub.platform_id"
|
||||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-indigo-600 bg-indigo-50 rounded-lg hover:bg-indigo-100 transition-colors">
|
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/20 rounded-lg hover:bg-purple-100 dark:hover:bg-purple-900/40 transition-colors">
|
||||||
View Details
|
View Details
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -109,28 +109,9 @@ function merchantSubscriptions() {
|
|||||||
this.loadSubscriptions();
|
this.loadSubscriptions();
|
||||||
},
|
},
|
||||||
|
|
||||||
getToken() {
|
|
||||||
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
|
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadSubscriptions() {
|
async loadSubscriptions() {
|
||||||
const token = this.getToken();
|
|
||||||
if (!token) {
|
|
||||||
window.location.href = '/merchants/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/v1/merchants/billing/subscriptions', {
|
const data = await apiClient.get('/merchants/billing/subscriptions');
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
if (resp.status === 401) {
|
|
||||||
window.location.href = '/merchants/login';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!resp.ok) throw new Error('Failed to load subscriptions');
|
|
||||||
const data = await resp.json();
|
|
||||||
this.subscriptions = data.subscriptions || data.items || [];
|
this.subscriptions = data.subscriptions || data.items || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading subscriptions:', err);
|
console.error('Error loading subscriptions:', err);
|
||||||
|
|||||||
93
app/modules/core/routes/pages/merchant.py
Normal file
93
app/modules/core/routes/pages/merchant.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# app/modules/core/routes/pages/merchant.py
|
||||||
|
"""
|
||||||
|
Core Merchant Page Routes (HTML rendering).
|
||||||
|
|
||||||
|
Merchant pages for core functionality:
|
||||||
|
- Login page
|
||||||
|
- Dashboard
|
||||||
|
- Root redirect
|
||||||
|
|
||||||
|
These are core concerns, not billing-specific, matching the admin pattern
|
||||||
|
where login/dashboard live in core (app/modules/core/routes/pages/admin.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_merchant_from_cookie_or_header, get_current_merchant_optional, get_db
|
||||||
|
from app.modules.core.utils.page_context import get_context_for_frontend
|
||||||
|
from app.modules.enums import FrontendType
|
||||||
|
from app.templates_config import templates
|
||||||
|
from models.schema.auth import UserContext
|
||||||
|
|
||||||
|
ROUTE_CONFIG = {
|
||||||
|
"prefix": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PUBLIC ROUTES (No Authentication Required)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=RedirectResponse, include_in_schema=False)
|
||||||
|
async def merchant_root(
|
||||||
|
current_user: UserContext | None = Depends(get_current_merchant_optional),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Redirect /merchants/ based on authentication status.
|
||||||
|
|
||||||
|
- Authenticated merchant users -> /merchants/dashboard
|
||||||
|
- Unauthenticated users -> /merchants/login
|
||||||
|
"""
|
||||||
|
if current_user:
|
||||||
|
return RedirectResponse(url="/merchants/dashboard", status_code=302)
|
||||||
|
|
||||||
|
return RedirectResponse(url="/merchants/login", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def merchant_login_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: UserContext | None = Depends(get_current_merchant_optional),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render merchant login page.
|
||||||
|
|
||||||
|
If user is already authenticated as merchant, redirect to dashboard.
|
||||||
|
Otherwise, show login form.
|
||||||
|
"""
|
||||||
|
if current_user:
|
||||||
|
return RedirectResponse(url="/merchants/dashboard", status_code=302)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("tenancy/merchant/login.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AUTHENTICATED ROUTES (Merchant Only)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def merchant_dashboard_page(
|
||||||
|
request: Request,
|
||||||
|
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Render merchant dashboard page.
|
||||||
|
Shows merchant overview with stores and subscriptions.
|
||||||
|
"""
|
||||||
|
context = get_context_for_frontend(
|
||||||
|
FrontendType.MERCHANT,
|
||||||
|
request,
|
||||||
|
db,
|
||||||
|
user=current_user,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"core/merchant/dashboard.html",
|
||||||
|
context,
|
||||||
|
)
|
||||||
134
app/modules/core/static/merchant/js/init-alpine.js
Normal file
134
app/modules/core/static/merchant/js/init-alpine.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
// app/modules/core/static/merchant/js/init-alpine.js
|
||||||
|
/**
|
||||||
|
* Alpine.js initialization for merchant pages
|
||||||
|
* Provides common data and methods for all merchant pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Use centralized logger
|
||||||
|
const merchantLog = window.LogConfig.log;
|
||||||
|
|
||||||
|
console.log('[MERCHANT INIT-ALPINE] Loading...');
|
||||||
|
|
||||||
|
// Sidebar section state persistence
|
||||||
|
const MERCHANT_SIDEBAR_STORAGE_KEY = 'merchant_sidebar_sections';
|
||||||
|
|
||||||
|
function getMerchantSidebarSectionsFromStorage() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(MERCHANT_SIDEBAR_STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[MERCHANT INIT-ALPINE] Failed to load sidebar state from localStorage:', e);
|
||||||
|
}
|
||||||
|
// Default: all sections open
|
||||||
|
return {
|
||||||
|
billing: true,
|
||||||
|
account: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMerchantSidebarSectionsToStorage(sections) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MERCHANT_SIDEBAR_STORAGE_KEY, JSON.stringify(sections));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[MERCHANT INIT-ALPINE] Failed to save sidebar state to localStorage:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function data() {
|
||||||
|
console.log('[MERCHANT INIT-ALPINE] data() function called');
|
||||||
|
return {
|
||||||
|
dark: false,
|
||||||
|
isSideMenuOpen: false,
|
||||||
|
isProfileMenuOpen: false,
|
||||||
|
currentPage: '',
|
||||||
|
merchantName: '',
|
||||||
|
|
||||||
|
// Sidebar collapsible sections state
|
||||||
|
openSections: getMerchantSidebarSectionsFromStorage(),
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Set current page from URL
|
||||||
|
const path = window.location.pathname;
|
||||||
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
// For /merchants/dashboard -> 'dashboard'
|
||||||
|
// For /merchants/billing/subscriptions -> 'subscriptions'
|
||||||
|
this.currentPage = segments[segments.length - 1] || 'dashboard';
|
||||||
|
|
||||||
|
// Load merchant name from JWT token
|
||||||
|
const token = localStorage.getItem('merchant_token');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
this.merchantName = payload.merchant_name || payload.sub || 'Merchant';
|
||||||
|
} catch (e) {
|
||||||
|
this.merchantName = 'Merchant';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load theme preference
|
||||||
|
const theme = localStorage.getItem('theme');
|
||||||
|
if (theme === 'dark') {
|
||||||
|
this.dark = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save last visited page (for redirect after login)
|
||||||
|
if (!path.includes('/login') &&
|
||||||
|
!path.includes('/logout') &&
|
||||||
|
!path.includes('/errors/')) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('merchant_last_visited_page', path);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore storage errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSideMenu() {
|
||||||
|
this.isSideMenuOpen = !this.isSideMenuOpen;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeSideMenu() {
|
||||||
|
this.isSideMenuOpen = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleProfileMenu() {
|
||||||
|
this.isProfileMenuOpen = !this.isProfileMenuOpen;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeProfileMenu() {
|
||||||
|
this.isProfileMenuOpen = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleTheme() {
|
||||||
|
this.dark = !this.dark;
|
||||||
|
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sidebar section toggle with persistence
|
||||||
|
toggleSection(section) {
|
||||||
|
this.openSections[section] = !this.openSections[section];
|
||||||
|
saveMerchantSidebarSectionsToStorage(this.openSections);
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleLogout() {
|
||||||
|
console.log('Logging out merchant user...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call logout API
|
||||||
|
await apiClient.post('/merchants/auth/logout');
|
||||||
|
console.log('Logout API called successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout API error (continuing anyway):', error);
|
||||||
|
} finally {
|
||||||
|
// Clear merchant tokens only
|
||||||
|
console.log('Clearing merchant tokens...');
|
||||||
|
localStorage.removeItem('merchant_token');
|
||||||
|
|
||||||
|
console.log('Redirecting to login...');
|
||||||
|
window.location.href = '/merchants/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
143
app/modules/core/static/merchant/js/login.js
Normal file
143
app/modules/core/static/merchant/js/login.js
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
// app/modules/core/static/merchant/js/login.js
|
||||||
|
// noqa: js-003 - Standalone login page, doesn't use base layout
|
||||||
|
// noqa: js-004 - No sidebar on login page, doesn't need currentPage
|
||||||
|
|
||||||
|
// Use centralized logger
|
||||||
|
const loginLog = window.LogConfig.createLogger('MERCHANT-LOGIN');
|
||||||
|
|
||||||
|
function merchantLogin() {
|
||||||
|
return {
|
||||||
|
dark: false,
|
||||||
|
credentials: {
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
success: null,
|
||||||
|
errors: {},
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Guard against multiple initialization
|
||||||
|
if (window._merchantLoginInitialized) return;
|
||||||
|
window._merchantLoginInitialized = true;
|
||||||
|
|
||||||
|
loginLog.info('=== MERCHANT LOGIN PAGE INITIALIZING ===');
|
||||||
|
|
||||||
|
// Just set theme - NO auth checking, NO redirecting!
|
||||||
|
// If user lands here with a valid token, the server-side route
|
||||||
|
// already handles the redirect. Don't redirect from JS or it
|
||||||
|
// creates an infinite loop with expired tokens.
|
||||||
|
this.dark = localStorage.getItem('theme') === 'dark';
|
||||||
|
|
||||||
|
const token = localStorage.getItem('merchant_token');
|
||||||
|
if (token) {
|
||||||
|
loginLog.warn('Found existing token on login page');
|
||||||
|
loginLog.info('Not redirecting - server handles auth redirect, clearing stale token');
|
||||||
|
localStorage.removeItem('merchant_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
loginLog.info('=== MERCHANT LOGIN PAGE INITIALIZATION COMPLETE ===');
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTokens() {
|
||||||
|
loginLog.debug('Clearing merchant auth tokens...');
|
||||||
|
localStorage.removeItem('merchant_token');
|
||||||
|
},
|
||||||
|
|
||||||
|
clearErrors() {
|
||||||
|
this.error = null;
|
||||||
|
this.success = null;
|
||||||
|
this.errors = {};
|
||||||
|
},
|
||||||
|
|
||||||
|
validateForm() {
|
||||||
|
this.clearErrors();
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
if (!this.credentials.email.trim()) {
|
||||||
|
this.errors.email = 'Email is required';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.credentials.password) {
|
||||||
|
this.errors.password = 'Password is required';
|
||||||
|
isValid = false;
|
||||||
|
} else if (this.credentials.password.length < 6) {
|
||||||
|
this.errors.password = 'Password must be at least 6 characters';
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleLogin() {
|
||||||
|
loginLog.info('=== MERCHANT LOGIN ATTEMPT STARTED ===');
|
||||||
|
|
||||||
|
if (!this.validateForm()) {
|
||||||
|
loginLog.warn('Form validation failed, aborting login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.clearErrors();
|
||||||
|
|
||||||
|
try {
|
||||||
|
loginLog.info('Calling merchant login API endpoint...');
|
||||||
|
|
||||||
|
const url = '/merchants/auth/login';
|
||||||
|
const payload = {
|
||||||
|
email_or_username: this.credentials.email.trim(),
|
||||||
|
password: this.credentials.password
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiClient.post(url, payload);
|
||||||
|
|
||||||
|
loginLog.info('Login API response received');
|
||||||
|
|
||||||
|
// Validate response
|
||||||
|
if (!response.access_token && !response.token) {
|
||||||
|
loginLog.error('Invalid response: No access token');
|
||||||
|
throw new Error('Invalid response from server - no token');
|
||||||
|
}
|
||||||
|
|
||||||
|
loginLog.info('Login successful, storing authentication data...');
|
||||||
|
|
||||||
|
// Store authentication data
|
||||||
|
const token = response.access_token || response.token;
|
||||||
|
localStorage.setItem('merchant_token', token);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
this.success = 'Login successful! Redirecting...';
|
||||||
|
|
||||||
|
// Check for last visited page
|
||||||
|
const lastPage = localStorage.getItem('merchant_last_visited_page');
|
||||||
|
const redirectTo = (lastPage && lastPage.startsWith('/merchants/') && !lastPage.includes('/login'))
|
||||||
|
? lastPage
|
||||||
|
: '/merchants/dashboard';
|
||||||
|
|
||||||
|
loginLog.info('Redirecting to:', redirectTo);
|
||||||
|
window.location.href = redirectTo;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
window.LogConfig.logError(error, 'MerchantLogin');
|
||||||
|
|
||||||
|
this.error = error.message || 'Invalid email or password. Please try again.';
|
||||||
|
|
||||||
|
// Only clear tokens on login FAILURE
|
||||||
|
this.clearTokens();
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
loginLog.info('=== MERCHANT LOGIN ATTEMPT FINISHED ===');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleDarkMode() {
|
||||||
|
this.dark = !this.dark;
|
||||||
|
localStorage.setItem('theme', this.dark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loginLog.info('Merchant login module loaded');
|
||||||
150
app/modules/core/templates/core/merchant/dashboard.html
Normal file
150
app/modules/core/templates/core/merchant/dashboard.html
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
{# app/modules/core/templates/core/merchant/dashboard.html #}
|
||||||
|
{% extends "merchant/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div x-data="merchantDashboard()">
|
||||||
|
|
||||||
|
<!-- Welcome -->
|
||||||
|
<div class="mb-8 mt-6">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Welcome back<span x-show="merchantName">, <span x-text="merchantName"></span></span></h2>
|
||||||
|
<p class="mt-1 text-gray-500 dark:text-gray-400">Here is an overview of your account.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="grid gap-6 mb-8 md:grid-cols-3">
|
||||||
|
<!-- Active Subscriptions -->
|
||||||
|
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
||||||
|
<span x-html="$icon('clipboard-list', 'w-6 h-6')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active Subscriptions</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.active_subscriptions">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total Stores -->
|
||||||
|
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-3 mr-4 text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/30 rounded-full">
|
||||||
|
<span x-html="$icon('shopping-bag', 'w-6 h-6')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Stores</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.total_stores">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Plan -->
|
||||||
|
<div class="flex items-center p-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-3 mr-4 text-purple-600 dark:text-purple-400 bg-purple-100 dark:bg-purple-900/30 rounded-full">
|
||||||
|
<span x-html="$icon('sparkles', 'w-6 h-6')"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Current Plan</p>
|
||||||
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100" x-text="stats.current_plan || '--'">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subscription Overview -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Subscription Overview</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<!-- Loading -->
|
||||||
|
<div x-show="loading" class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="inline w-6 h-6 animate-spin mr-2" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subscriptions list -->
|
||||||
|
<div x-show="!loading && subscriptions.length > 0" class="space-y-4">
|
||||||
|
<template x-for="sub in subscriptions" :key="sub.id">
|
||||||
|
<div class="flex items-center justify-between p-4 border border-gray-100 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-gray-900 dark:text-gray-100" x-text="sub.platform_name || 'Subscription'"></p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span x-text="sub.tier" class="capitalize"></span> ·
|
||||||
|
Renews <span x-text="formatDate(sub.period_end)"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="px-3 py-1 text-xs font-semibold rounded-full"
|
||||||
|
:class="{
|
||||||
|
'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400': sub.status === 'active',
|
||||||
|
'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400': sub.status === 'trial',
|
||||||
|
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400': sub.status === 'past_due',
|
||||||
|
'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400': sub.status === 'cancelled'
|
||||||
|
}"
|
||||||
|
x-text="sub.status.replace('_', ' ')"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div x-show="!loading && subscriptions.length === 0" class="text-center py-8">
|
||||||
|
<span x-html="$icon('clipboard-list', 'w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600')"></span>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">No active subscriptions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
function merchantDashboard() {
|
||||||
|
return {
|
||||||
|
loading: true,
|
||||||
|
merchantName: '',
|
||||||
|
stats: {
|
||||||
|
active_subscriptions: '--',
|
||||||
|
total_stores: '--',
|
||||||
|
current_plan: '--'
|
||||||
|
},
|
||||||
|
subscriptions: [],
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Get merchant name from parent component
|
||||||
|
const token = localStorage.getItem('merchant_token');
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
this.merchantName = payload.merchant_name || '';
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
this.loadDashboard();
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadDashboard() {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.get('/merchants/billing/subscriptions');
|
||||||
|
this.subscriptions = data.subscriptions || data.items || [];
|
||||||
|
|
||||||
|
const active = this.subscriptions.filter(s => s.status === 'active' || s.status === 'trial');
|
||||||
|
this.stats.active_subscriptions = active.length;
|
||||||
|
this.stats.total_stores = this.subscriptions.length;
|
||||||
|
this.stats.current_plan = active.length > 0
|
||||||
|
? active[0].tier.charAt(0).toUpperCase() + active[0].tier.slice(1)
|
||||||
|
: 'None';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load dashboard:', error);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '-';
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
"""
|
"""
|
||||||
Tenancy module merchant API routes.
|
Tenancy module merchant API routes.
|
||||||
|
|
||||||
Provides merchant-facing API endpoints for the merchant portal:
|
Aggregates all merchant tenancy routes:
|
||||||
- /account/stores - List merchant's stores
|
- /auth/* - Merchant authentication (login, logout, /me)
|
||||||
- /account/profile - Get/update merchant profile
|
- /account/* - Merchant account management (stores, profile)
|
||||||
|
|
||||||
Auto-discovered by the route system (merchant.py in routes/api/).
|
Auto-discovered by the route system (merchant.py in routes/api/).
|
||||||
"""
|
"""
|
||||||
@@ -21,13 +21,17 @@ from app.core.database import get_db
|
|||||||
from app.modules.tenancy.models import Merchant
|
from app.modules.tenancy.models import Merchant
|
||||||
from models.schema.auth import UserContext
|
from models.schema.auth import UserContext
|
||||||
|
|
||||||
|
from .merchant_auth import merchant_auth_router
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
ROUTE_CONFIG = {
|
# Include auth routes (/auth/login, /auth/logout, /auth/me)
|
||||||
"prefix": "/account",
|
router.include_router(merchant_auth_router, tags=["merchant-auth"])
|
||||||
}
|
|
||||||
|
# Account routes are defined below with /account prefix
|
||||||
|
_account_router = APIRouter(prefix="/account")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -81,11 +85,11 @@ def _get_user_merchant(db: Session, user_context: UserContext) -> Merchant:
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ENDPOINTS
|
# ACCOUNT ENDPOINTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stores")
|
@_account_router.get("/stores")
|
||||||
async def merchant_stores(
|
async def merchant_stores(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||||
@@ -114,7 +118,7 @@ async def merchant_stores(
|
|||||||
return {"stores": stores}
|
return {"stores": stores}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/profile")
|
@_account_router.get("/profile")
|
||||||
async def merchant_profile(
|
async def merchant_profile(
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||||
@@ -140,7 +144,7 @@ async def merchant_profile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/profile")
|
@_account_router.put("/profile")
|
||||||
async def update_merchant_profile(
|
async def update_merchant_profile(
|
||||||
request: Request,
|
request: Request,
|
||||||
profile_data: MerchantProfileUpdate,
|
profile_data: MerchantProfileUpdate,
|
||||||
@@ -177,3 +181,7 @@ async def update_merchant_profile(
|
|||||||
"tax_number": merchant.tax_number,
|
"tax_number": merchant.tax_number,
|
||||||
"is_verified": merchant.is_verified,
|
"is_verified": merchant.is_verified,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Include account routes in main router
|
||||||
|
router.include_router(_account_router, tags=["merchant-account"])
|
||||||
|
|||||||
117
app/modules/tenancy/routes/api/merchant_auth.py
Normal file
117
app/modules/tenancy/routes/api/merchant_auth.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# app/modules/tenancy/routes/api/merchant_auth.py
|
||||||
|
"""
|
||||||
|
Merchant authentication endpoints.
|
||||||
|
|
||||||
|
Implements dual token storage with path restriction:
|
||||||
|
- Sets HTTP-only cookie with path=/merchants (restricted to merchant routes only)
|
||||||
|
- Returns token in response for localStorage (API calls)
|
||||||
|
|
||||||
|
This prevents merchant cookies from being sent to admin or store routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_merchant_from_cookie_or_header
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.environment import should_use_secure_cookies
|
||||||
|
from app.modules.core.services.auth_service import auth_service
|
||||||
|
from models.schema.auth import LoginResponse, LogoutResponse, UserLogin, UserResponse, UserContext
|
||||||
|
|
||||||
|
merchant_auth_router = APIRouter(prefix="/auth")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@merchant_auth_router.post("/login", response_model=LoginResponse)
|
||||||
|
def merchant_login(
|
||||||
|
user_credentials: UserLogin, response: Response, db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Merchant login endpoint.
|
||||||
|
|
||||||
|
Only allows users who own at least one active merchant to login.
|
||||||
|
Returns JWT token for authenticated merchant users.
|
||||||
|
|
||||||
|
Sets token in two places:
|
||||||
|
1. HTTP-only cookie with path=/merchants (for browser page navigation)
|
||||||
|
2. Response body (for localStorage and API calls)
|
||||||
|
|
||||||
|
The cookie is restricted to /merchants/* routes only to prevent
|
||||||
|
it from being sent to admin or store routes.
|
||||||
|
"""
|
||||||
|
# Authenticate user and verify merchant ownership
|
||||||
|
login_result = auth_service.login_merchant(db=db, user_credentials=user_credentials)
|
||||||
|
|
||||||
|
logger.info(f"Merchant login successful: {login_result['user'].username}")
|
||||||
|
|
||||||
|
# Set HTTP-only cookie for browser navigation
|
||||||
|
# CRITICAL: path=/merchants restricts cookie to merchant routes only
|
||||||
|
response.set_cookie(
|
||||||
|
key="merchant_token",
|
||||||
|
value=login_result["token_data"]["access_token"],
|
||||||
|
httponly=True, # JavaScript cannot access (XSS protection)
|
||||||
|
secure=should_use_secure_cookies(), # HTTPS only in production/staging
|
||||||
|
samesite="lax", # CSRF protection
|
||||||
|
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
|
||||||
|
path="/merchants", # RESTRICTED TO MERCHANT ROUTES ONLY
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Set merchant_token cookie with {login_result['token_data']['expires_in']}s expiry "
|
||||||
|
f"(path=/merchants, httponly=True, secure={should_use_secure_cookies()})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also return token in response for localStorage (API calls)
|
||||||
|
return LoginResponse(
|
||||||
|
access_token=login_result["token_data"]["access_token"],
|
||||||
|
token_type=login_result["token_data"]["token_type"],
|
||||||
|
expires_in=login_result["token_data"]["expires_in"],
|
||||||
|
user=login_result["user"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@merchant_auth_router.get("/me", response_model=UserResponse)
|
||||||
|
def get_current_merchant(
|
||||||
|
current_user: UserContext = Depends(get_current_merchant_from_cookie_or_header),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get current authenticated merchant user.
|
||||||
|
|
||||||
|
This endpoint validates the token and ensures the user owns merchants.
|
||||||
|
Returns the current user's information.
|
||||||
|
|
||||||
|
Token can come from:
|
||||||
|
- Authorization header (API calls)
|
||||||
|
- merchant_token cookie (browser navigation, path=/merchants only)
|
||||||
|
"""
|
||||||
|
logger.info(f"Merchant user info requested: {current_user.username}")
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
@merchant_auth_router.post("/logout", response_model=LogoutResponse)
|
||||||
|
def merchant_logout(response: Response):
|
||||||
|
"""
|
||||||
|
Merchant logout endpoint.
|
||||||
|
|
||||||
|
Clears the merchant_token cookie.
|
||||||
|
Client should also remove token from localStorage.
|
||||||
|
"""
|
||||||
|
logger.info("Merchant logout")
|
||||||
|
|
||||||
|
# Clear the cookie (must match path used when setting)
|
||||||
|
response.delete_cookie(
|
||||||
|
key="merchant_token",
|
||||||
|
path="/merchants",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also clear legacy cookie with path=/ (from before path isolation was added)
|
||||||
|
response.delete_cookie(
|
||||||
|
key="merchant_token",
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug("Deleted merchant_token cookies (both /merchants and / paths)")
|
||||||
|
|
||||||
|
return LogoutResponse(message="Logged out successfully")
|
||||||
141
app/modules/tenancy/templates/tenancy/merchant/login.html
Normal file
141
app/modules/tenancy/templates/tenancy/merchant/login.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
{# app/modules/tenancy/templates/tenancy/merchant/login.html #}
|
||||||
|
{# Standalone login page - does NOT extend merchant/base.html #}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html :class="{ 'dark': dark }" x-data="merchantLogin()" lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Merchant Login - Wizamart</title>
|
||||||
|
<!-- Fonts: Local fallback + Google Fonts -->
|
||||||
|
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='merchant/css/tailwind.output.css') }}" />
|
||||||
|
<style>
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="flex items-center min-h-screen p-6 bg-gray-50 dark:bg-gray-900" x-cloak>
|
||||||
|
<div class="flex-1 h-full max-w-4xl mx-auto overflow-hidden bg-white rounded-lg shadow-xl dark:bg-gray-800">
|
||||||
|
<div class="flex flex-col overflow-y-auto md:flex-row">
|
||||||
|
<div class="h-32 md:h-auto md:w-1/2">
|
||||||
|
<img aria-hidden="true" class="object-cover w-full h-full dark:hidden"
|
||||||
|
src="{{ url_for('static', path='admin/img/login-office.jpeg') }}" alt="Office" />
|
||||||
|
<img aria-hidden="true" class="hidden object-cover w-full h-full dark:block"
|
||||||
|
src="{{ url_for('static', path='admin/img/login-office-dark.jpeg') }}" alt="Office" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-center p-6 sm:p-12 md:w-1/2">
|
||||||
|
<div class="w-full">
|
||||||
|
<h1 class="mb-4 text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Merchant Login
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Alert Messages -->
|
||||||
|
<div x-show="error" x-text="error"
|
||||||
|
class="px-4 py-3 mb-4 text-sm text-red-700 bg-red-100 rounded-lg dark:bg-red-200 dark:text-red-800"
|
||||||
|
x-transition></div>
|
||||||
|
|
||||||
|
<div x-show="success" x-text="success"
|
||||||
|
class="px-4 py-3 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800"
|
||||||
|
x-transition></div>
|
||||||
|
|
||||||
|
<!-- Login Form -->
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<label class="block text-sm">
|
||||||
|
<span class="text-gray-700 dark:text-gray-400">Email</span>
|
||||||
|
<input x-model="credentials.email"
|
||||||
|
:disabled="loading"
|
||||||
|
@input="clearErrors"
|
||||||
|
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||||
|
:class="{ 'border-red-600': errors.email }"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autocomplete="username"
|
||||||
|
required />
|
||||||
|
<span x-show="errors.email" x-text="errors.email"
|
||||||
|
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="block mt-4 text-sm">
|
||||||
|
<span class="text-gray-700 dark:text-gray-400">Password</span>
|
||||||
|
<input x-model="credentials.password"
|
||||||
|
:disabled="loading"
|
||||||
|
@input="clearErrors"
|
||||||
|
class="block w-full mt-1 text-sm dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:text-gray-300 dark:focus:shadow-outline-gray form-input"
|
||||||
|
:class="{ 'border-red-600': errors.password }"
|
||||||
|
placeholder="***************"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required />
|
||||||
|
<span x-show="errors.password" x-text="errors.password"
|
||||||
|
class="text-xs text-red-600 dark:text-red-400"></span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{# noqa: FE-002 - Inline spinner SVG for loading state #}
|
||||||
|
<button type="submit" :disabled="loading"
|
||||||
|
class="block w-full px-4 py-2 mt-4 text-sm font-medium leading-5 text-center text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
<span x-show="!loading">Sign in</span>
|
||||||
|
<span x-show="loading" class="flex items-center justify-center">
|
||||||
|
<svg class="inline w-4 h-4 mr-2 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Signing in...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<hr class="my-8" />
|
||||||
|
|
||||||
|
<p class="mt-4">
|
||||||
|
<a class="text-sm font-medium text-purple-600 dark:text-purple-400 hover:underline"
|
||||||
|
href="#">
|
||||||
|
Forgot your password?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
<a class="text-sm font-medium text-gray-600 dark:text-gray-400 hover:underline"
|
||||||
|
href="/">
|
||||||
|
← Back to Platform
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts - ORDER MATTERS! -->
|
||||||
|
|
||||||
|
<!-- 1. Log Configuration -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 2. Icons -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 3. Utils -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 4. API Client -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 5. Alpine.js v3 with CDN fallback -->
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var script = document.createElement('script');
|
||||||
|
script.defer = true;
|
||||||
|
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.14.0/dist/cdn.min.js';
|
||||||
|
script.onerror = function() {
|
||||||
|
console.warn('Alpine.js CDN failed, loading local copy...');
|
||||||
|
var fallbackScript = document.createElement('script');
|
||||||
|
fallbackScript.defer = true;
|
||||||
|
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/alpine.min.js") }}';
|
||||||
|
document.head.appendChild(fallbackScript);
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- 6. Merchant Login Logic -->
|
||||||
|
<script src="{{ url_for('core_static', path='merchant/js/login.js') }}"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,196 +1,80 @@
|
|||||||
{# app/templates/merchant/base.html #}
|
{# app/templates/merchant/base.html #}
|
||||||
{# Base template for the merchant billing portal #}
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html :class="{ 'dark': dark }" x-data="{% block alpine_data %}data(){% endblock %}" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}Merchant Portal{% endblock %} - Wizamart</title>
|
<title>{% block title %}Merchant Portal{% endblock %} - Wizamart</title>
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts: Local fallback + Google Fonts -->
|
||||||
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
<link href="/static/shared/fonts/inter.css" rel="stylesheet" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
<!-- Tailwind CSS v4 (built locally via standalone CLI) -->
|
||||||
<link rel="stylesheet" href="/static/admin/css/tailwind.output.css" />
|
<link rel="stylesheet" href="{{ url_for('static', path='merchant/css/tailwind.output.css') }}" />
|
||||||
|
|
||||||
<!-- Alpine Cloak -->
|
<!-- Alpine Cloak -->
|
||||||
<style>
|
<style>
|
||||||
[x-cloak] { display: none !important; }
|
[x-cloak] { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 font-sans" x-data="merchantApp()" x-cloak>
|
<body x-cloak>
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen bg-gray-50 dark:bg-gray-900" :class="{ 'overflow-hidden': isSideMenuOpen }">
|
||||||
|
<!-- Sidebar (server-side included) -->
|
||||||
|
{% include 'merchant/partials/sidebar.html' %}
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<div class="flex flex-col flex-1 w-full">
|
||||||
<aside class="hidden md:flex md:flex-shrink-0">
|
<!-- Header (server-side included) -->
|
||||||
<div class="flex flex-col w-64 bg-indigo-900">
|
{% include 'merchant/partials/header.html' %}
|
||||||
<!-- Logo / Brand -->
|
|
||||||
<div class="flex items-center h-16 px-6 bg-indigo-950">
|
|
||||||
<a href="/merchants/billing/" class="flex items-center space-x-2">
|
|
||||||
<svg class="w-8 h-8 text-indigo-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-lg font-bold text-white">Merchant Portal</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Main Content -->
|
||||||
<nav class="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
<main class="h-full overflow-y-auto">
|
||||||
<a href="/merchants/billing/"
|
<div class="container px-6 mx-auto grid">
|
||||||
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
|
|
||||||
:class="currentPath === '/merchants/billing/' ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4"/>
|
|
||||||
</svg>
|
|
||||||
Dashboard
|
|
||||||
</a>
|
|
||||||
<a href="/merchants/billing/subscriptions"
|
|
||||||
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
|
|
||||||
:class="currentPath.startsWith('/merchants/billing/subscription') ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
|
||||||
</svg>
|
|
||||||
Subscriptions
|
|
||||||
</a>
|
|
||||||
<a href="/merchants/billing/billing"
|
|
||||||
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
|
|
||||||
:class="currentPath.startsWith('/merchants/billing/billing') ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
Billing History
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="pt-4 mt-4 border-t border-indigo-800">
|
|
||||||
<p class="px-3 mb-2 text-xs font-semibold tracking-wider text-indigo-400 uppercase">Account</p>
|
|
||||||
</div>
|
|
||||||
<a href="/merchants/account/stores"
|
|
||||||
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
|
|
||||||
:class="currentPath.startsWith('/merchants/account/stores') ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
|
|
||||||
</svg>
|
|
||||||
Stores
|
|
||||||
</a>
|
|
||||||
<a href="/merchants/account/profile"
|
|
||||||
class="flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors"
|
|
||||||
:class="currentPath.startsWith('/merchants/account/profile') ? 'bg-indigo-800 text-white' : 'text-indigo-200 hover:bg-indigo-800 hover:text-white'">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
|
||||||
</svg>
|
|
||||||
Profile
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<div class="flex flex-col flex-1 w-full overflow-hidden">
|
|
||||||
|
|
||||||
<!-- Top Header -->
|
|
||||||
<header class="flex items-center justify-between h-16 px-6 bg-white border-b border-gray-200">
|
|
||||||
<!-- Mobile menu button -->
|
|
||||||
<button @click="sidebarOpen = !sidebarOpen" class="md:hidden p-2 rounded-md text-gray-500 hover:text-gray-700 focus:outline-none">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex-1"></div>
|
|
||||||
|
|
||||||
<!-- Merchant info and logout -->
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<span class="text-sm font-medium text-gray-700" x-text="merchantName || 'Merchant'"></span>
|
|
||||||
<button @click="logout()"
|
|
||||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors">
|
|
||||||
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
|
||||||
</svg>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Mobile Sidebar Overlay -->
|
|
||||||
<div x-show="sidebarOpen" x-cloak
|
|
||||||
class="fixed inset-0 z-40 md:hidden"
|
|
||||||
@click="sidebarOpen = false">
|
|
||||||
<div class="fixed inset-0 bg-gray-600 bg-opacity-50"></div>
|
|
||||||
<div class="fixed inset-y-0 left-0 w-64 bg-indigo-900 z-50">
|
|
||||||
<div class="flex items-center justify-between h-16 px-6 bg-indigo-950">
|
|
||||||
<span class="text-lg font-bold text-white">Merchant Portal</span>
|
|
||||||
<button @click="sidebarOpen = false" class="text-indigo-300 hover:text-white">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<nav class="px-4 py-6 space-y-1">
|
|
||||||
<a href="/merchants/billing/" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Dashboard</a>
|
|
||||||
<a href="/merchants/billing/subscriptions" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Subscriptions</a>
|
|
||||||
<a href="/merchants/billing/billing" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Billing History</a>
|
|
||||||
<a href="/merchants/account/stores" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Stores</a>
|
|
||||||
<a href="/merchants/account/profile" class="flex items-center px-3 py-2 text-sm font-medium text-indigo-200 rounded-lg hover:bg-indigo-800 hover:text-white">Profile</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Page Content -->
|
|
||||||
<main class="flex-1 overflow-y-auto">
|
|
||||||
<div class="container px-6 py-8 mx-auto">
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alpine.js -->
|
<!-- Core Scripts - ORDER MATTERS! -->
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Base merchant app data -->
|
<!-- 1. FIRST: Log Configuration -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/log-config.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 2. SECOND: Icons (before Alpine.js) -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/icons.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 3. THIRD: Alpine.js Base Data -->
|
||||||
|
<script src="{{ url_for('core_static', path='merchant/js/init-alpine.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 4. FOURTH: Utils (standalone utilities) -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/utils.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 5. FIFTH: API Client (depends on Utils) -->
|
||||||
|
<script src="{{ url_for('static', path='shared/js/api-client.js') }}"></script>
|
||||||
|
|
||||||
|
<!-- 6. SIXTH: Alpine.js v3 with CDN fallback (with defer) -->
|
||||||
<script>
|
<script>
|
||||||
function merchantApp() {
|
(function() {
|
||||||
return {
|
var script = document.createElement('script');
|
||||||
sidebarOpen: false,
|
script.defer = true;
|
||||||
currentPath: window.location.pathname,
|
script.src = 'https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js';
|
||||||
merchantName: '',
|
|
||||||
|
|
||||||
init() {
|
script.onerror = function() {
|
||||||
// Load merchant name from token/cookie
|
console.warn('Alpine.js CDN failed, loading local copy...');
|
||||||
const token = this.getToken();
|
var fallbackScript = document.createElement('script');
|
||||||
if (token) {
|
fallbackScript.defer = true;
|
||||||
try {
|
fallbackScript.src = '{{ url_for("static", path="shared/js/lib/alpine.min.js") }}';
|
||||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
document.head.appendChild(fallbackScript);
|
||||||
this.merchantName = payload.merchant_name || payload.sub || 'Merchant';
|
|
||||||
} catch (e) {
|
|
||||||
this.merchantName = 'Merchant';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getToken() {
|
|
||||||
// Read merchant_token from cookie
|
|
||||||
const match = document.cookie.match(/(?:^|;\s*)merchant_token=([^;]*)/);
|
|
||||||
return match ? decodeURIComponent(match[1]) : null;
|
|
||||||
},
|
|
||||||
|
|
||||||
logout() {
|
|
||||||
// Clear merchant_token cookie
|
|
||||||
document.cookie = 'merchant_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
||||||
window.location.href = '/merchants/login';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- 7. LAST: Page-specific scripts -->
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
70
app/templates/merchant/partials/header.html
Normal file
70
app/templates/merchant/partials/header.html
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{# app/templates/merchant/partials/header.html #}
|
||||||
|
<header class="z-10 py-4 bg-white shadow-md dark:bg-gray-800">
|
||||||
|
<div class="container flex items-center justify-between h-full px-6 mx-auto text-purple-600 dark:text-purple-300">
|
||||||
|
<!-- Mobile hamburger -->
|
||||||
|
<button class="p-1 mr-5 -ml-1 rounded-md md:hidden focus:outline-none focus:shadow-outline-purple"
|
||||||
|
@click="toggleSideMenu"
|
||||||
|
aria-label="Menu">
|
||||||
|
<span x-html="$icon('menu', 'w-6 h-6')"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Spacer -->
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<ul class="flex items-center flex-shrink-0 space-x-6">
|
||||||
|
<!-- Theme toggler -->
|
||||||
|
<li class="flex">
|
||||||
|
<button class="rounded-md focus:outline-none focus:shadow-outline-purple"
|
||||||
|
@click="toggleTheme"
|
||||||
|
aria-label="Toggle color mode">
|
||||||
|
<template x-if="!dark">
|
||||||
|
<span x-html="$icon('moon', 'w-5 h-5')"></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="dark">
|
||||||
|
<span x-html="$icon('sun', 'w-5 h-5')"></span>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<!-- Profile menu -->
|
||||||
|
<li class="relative" x-data="{ profileOpen: false }">
|
||||||
|
<button class="align-middle rounded-full focus:shadow-outline-purple focus:outline-none"
|
||||||
|
@click="profileOpen = !profileOpen"
|
||||||
|
@keydown.escape="profileOpen = false"
|
||||||
|
aria-label="Account"
|
||||||
|
aria-haspopup="true">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-purple-600 flex items-center justify-center text-white font-semibold">
|
||||||
|
<span x-text="merchantName?.charAt(0).toUpperCase() || '?'"></span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul x-show="profileOpen"
|
||||||
|
x-cloak
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
@click.away="profileOpen = false"
|
||||||
|
@keydown.escape="profileOpen = false"
|
||||||
|
class="absolute right-0 w-56 p-2 mt-2 space-y-2 text-gray-600 bg-white border border-gray-100 rounded-md shadow-md dark:border-gray-700 dark:text-gray-300 dark:bg-gray-700 z-50"
|
||||||
|
style="display: none;"
|
||||||
|
aria-label="submenu">
|
||||||
|
<li class="flex">
|
||||||
|
<a class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||||
|
href="/merchants/account/profile">
|
||||||
|
<span x-html="$icon('user', 'w-4 h-4 mr-3')"></span>
|
||||||
|
<span>Profile</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="flex">
|
||||||
|
<button
|
||||||
|
@click="handleLogout()"
|
||||||
|
class="inline-flex items-center w-full px-2 py-1 text-sm font-semibold transition-colors duration-150 rounded-md hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200 text-left">
|
||||||
|
<span x-html="$icon('logout', 'w-4 h-4 mr-3')"></span>
|
||||||
|
<span>Log out</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
127
app/templates/merchant/partials/sidebar.html
Normal file
127
app/templates/merchant/partials/sidebar.html
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{# app/templates/merchant/partials/sidebar.html #}
|
||||||
|
{# Collapsible sidebar sections with localStorage persistence - matching store pattern #}
|
||||||
|
|
||||||
|
{# ============================================================================
|
||||||
|
REUSABLE MACROS FOR SIDEBAR ITEMS
|
||||||
|
============================================================================ #}
|
||||||
|
|
||||||
|
{# Macro for collapsible section header #}
|
||||||
|
{% macro section_header(title, section_key, icon=none) %}
|
||||||
|
<div class="px-6 my-4">
|
||||||
|
<hr class="border-gray-200 dark:border-gray-700" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="toggleSection('{{ section_key }}')"
|
||||||
|
class="flex items-center justify-between w-full px-6 py-2 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<span class="flex items-center">
|
||||||
|
{% if icon %}
|
||||||
|
<span x-html="$icon('{{ icon }}', 'w-4 h-4 mr-2 text-gray-400')"></span>
|
||||||
|
{% endif %}
|
||||||
|
{{ title }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
x-html="$icon('chevron-down', 'w-4 h-4 transition-transform duration-200')"
|
||||||
|
:class="{ 'rotate-180': openSections.{{ section_key }} }"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# Macro for collapsible section content wrapper #}
|
||||||
|
{% macro section_content(section_key) %}
|
||||||
|
<ul
|
||||||
|
x-show="openSections.{{ section_key }}"
|
||||||
|
x-transition:enter="transition-all duration-200 ease-out"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave="transition-all duration-150 ease-in"
|
||||||
|
x-transition:leave-start="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||||
|
class="mt-1 overflow-hidden"
|
||||||
|
>
|
||||||
|
{{ caller() }}
|
||||||
|
</ul>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# Macro for menu item - uses static href (no storeCode needed) #}
|
||||||
|
{% macro menu_item(page_id, path, icon, label) %}
|
||||||
|
<li class="relative px-6 py-3">
|
||||||
|
<span x-show="currentPage === '{{ page_id }}'" class="absolute inset-y-0 left-0 w-1 bg-purple-600 rounded-tr-lg rounded-br-lg" aria-hidden="true"></span>
|
||||||
|
<a class="inline-flex items-center w-full text-sm font-semibold transition-colors duration-150 hover:text-gray-800 dark:hover:text-gray-200"
|
||||||
|
:class="currentPage === '{{ page_id }}' ? 'text-gray-800 dark:text-gray-100' : ''"
|
||||||
|
href="{{ path }}">
|
||||||
|
<span x-html="$icon('{{ icon }}', 'w-5 h-5')"></span>
|
||||||
|
<span class="ml-4">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# ============================================================================
|
||||||
|
SIDEBAR CONTENT (shared between desktop and mobile)
|
||||||
|
============================================================================ #}
|
||||||
|
|
||||||
|
{% macro sidebar_content() %}
|
||||||
|
<div class="py-4 text-gray-500 dark:text-gray-400">
|
||||||
|
<!-- Merchant Branding -->
|
||||||
|
<a class="ml-6 text-lg font-bold text-gray-800 dark:text-gray-200 flex items-center"
|
||||||
|
href="/merchants/dashboard">
|
||||||
|
<span x-html="$icon('lightning-bolt', 'w-6 h-6 mr-2 text-purple-600')"></span>
|
||||||
|
<span>Merchant Portal</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Dashboard (always visible) -->
|
||||||
|
<ul class="mt-6">
|
||||||
|
{{ menu_item('dashboard', '/merchants/dashboard', 'home', 'Dashboard') }}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Billing Section -->
|
||||||
|
{{ section_header('Billing', 'billing', 'credit-card') }}
|
||||||
|
{% call section_content('billing') %}
|
||||||
|
{{ menu_item('subscriptions', '/merchants/billing/subscriptions', 'clipboard-list', 'Subscriptions') }}
|
||||||
|
{{ menu_item('billing', '/merchants/billing/billing', 'currency-euro', 'Billing History') }}
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
<!-- Account Section -->
|
||||||
|
{{ section_header('Account', 'account', 'cog') }}
|
||||||
|
{% call section_content('account') %}
|
||||||
|
{{ menu_item('stores', '/merchants/account/stores', 'shopping-bag', 'Stores') }}
|
||||||
|
{{ menu_item('profile', '/merchants/account/profile', 'user', 'Profile') }}
|
||||||
|
{% endcall %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
{# ============================================================================
|
||||||
|
DESKTOP SIDEBAR
|
||||||
|
============================================================================ #}
|
||||||
|
|
||||||
|
<aside class="z-20 hidden w-64 overflow-y-auto bg-white dark:bg-gray-800 md:block flex-shrink-0">
|
||||||
|
{{ sidebar_content() }}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{# ============================================================================
|
||||||
|
MOBILE SIDEBAR
|
||||||
|
============================================================================ #}
|
||||||
|
|
||||||
|
<!-- Mobile sidebar backdrop -->
|
||||||
|
<div x-show="isSideMenuOpen"
|
||||||
|
x-transition:enter="transition ease-in-out duration-150"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in-out duration-150"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
class="fixed inset-0 z-10 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"></div>
|
||||||
|
|
||||||
|
<!-- Mobile sidebar panel -->
|
||||||
|
<aside class="fixed inset-y-0 z-20 flex-shrink-0 w-64 mt-16 overflow-y-auto bg-white dark:bg-gray-800 md:hidden"
|
||||||
|
x-show="isSideMenuOpen"
|
||||||
|
x-transition:enter="transition ease-in-out duration-150"
|
||||||
|
x-transition:enter-start="opacity-0 transform -translate-x-20"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in-out duration-150"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0 transform -translate-x-20"
|
||||||
|
@click.away="closeSideMenu"
|
||||||
|
@keydown.escape="closeSideMenu">
|
||||||
|
{{ sidebar_content() }}
|
||||||
|
</aside>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Session Date:** 2026-01-18
|
**Session Date:** 2026-01-18
|
||||||
**Status:** Initial Analysis - Requirements Captured
|
**Status:** Initial Analysis - Requirements Captured
|
||||||
**Related:** [Loyalty Program Analysis](./loyalty-program-analysis.md)
|
**Related:** [Loyalty Program Analysis](../proposals/loyalty-program-analysis.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
302
docs/deployment/gitea.md
Normal file
302
docs/deployment/gitea.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# Gitea CI/CD Deployment Guide
|
||||||
|
|
||||||
|
This document describes how to **self-host Gitea** on an external server and migrate CI/CD from GitLab to **Gitea Actions** (GitHub Actions-compatible).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why Gitea?
|
||||||
|
|
||||||
|
- Lightweight, self-hosted Git forge (single binary or Docker image)
|
||||||
|
- Built-in CI/CD via **Gitea Actions** (GitHub Actions-compatible YAML)
|
||||||
|
- Built-in migration tool imports repos, issues, and PRs from GitLab
|
||||||
|
- Low resource usage compared to GitLab
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Server Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt update && sudo apt install -y docker.io docker-compose-plugin
|
||||||
|
sudo systemctl enable --now docker
|
||||||
|
sudo usermod -aG docker $USER # log out/in after this
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gitea Server Setup (Docker Compose)
|
||||||
|
|
||||||
|
Create a directory on your server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/gitea && cd ~/gitea
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:latest
|
||||||
|
container_name: gitea
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
|
- GITEA__database__DB_TYPE=postgres
|
||||||
|
- GITEA__database__HOST=gitea-db:5432
|
||||||
|
- GITEA__database__NAME=gitea
|
||||||
|
- GITEA__database__USER=gitea
|
||||||
|
- GITEA__database__PASSWD=<CHANGE_ME>
|
||||||
|
- GITEA__server__ROOT_URL=https://git.yourdomain.com/
|
||||||
|
- GITEA__server__SSH_DOMAIN=git.yourdomain.com
|
||||||
|
- GITEA__server__DOMAIN=git.yourdomain.com
|
||||||
|
- GITEA__actions__ENABLED=true
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/data
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # Web UI
|
||||||
|
- "2222:22" # SSH (Git over SSH)
|
||||||
|
depends_on:
|
||||||
|
gitea-db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
gitea-db:
|
||||||
|
image: postgres:15
|
||||||
|
container_name: gitea-db
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: gitea
|
||||||
|
POSTGRES_USER: gitea
|
||||||
|
POSTGRES_PASSWORD: <CHANGE_ME>
|
||||||
|
volumes:
|
||||||
|
- gitea-db-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U gitea"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# Gitea Actions runner — executes CI workflows
|
||||||
|
gitea-runner:
|
||||||
|
image: gitea/act_runner:latest
|
||||||
|
container_name: gitea-runner
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
GITEA_INSTANCE_URL: http://gitea:3000
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN: <RUNNER_TOKEN>
|
||||||
|
GITEA_RUNNER_NAME: default-runner
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- gitea-runner-data:/data
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea-data:
|
||||||
|
gitea-db-data:
|
||||||
|
gitea-runner-data:
|
||||||
|
```
|
||||||
|
|
||||||
|
!!! warning "Replace placeholders"
|
||||||
|
Replace `<CHANGE_ME>` with a strong database password and `<RUNNER_TOKEN>` with the token from step 4.
|
||||||
|
|
||||||
|
Start Gitea and the database first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d gitea gitea-db
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://your-server-ip:3000` and complete the initial setup wizard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Reverse Proxy with HTTPS (Nginx + Let's Encrypt)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y nginx certbot python3-certbot-nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `/etc/nginx/sites-available/gitea`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
server_name git.yourdomain.com;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable and get a certificate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/
|
||||||
|
sudo certbot --nginx -d git.yourdomain.com
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Enable Actions & Register the Runner
|
||||||
|
|
||||||
|
1. Go to **Site Administration > Runners** in the Gitea web UI.
|
||||||
|
2. Click **Create new Runner** and copy the registration token.
|
||||||
|
3. Paste the token into `docker-compose.yml` as `GITEA_RUNNER_REGISTRATION_TOKEN`.
|
||||||
|
4. Start the runner:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d gitea-runner
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify the runner appears as **Online** in the admin panel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Migrate Your Repository
|
||||||
|
|
||||||
|
### Option A: Git push (code only)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On your local machine
|
||||||
|
cd /path/to/letzshop-product-import
|
||||||
|
git remote add gitea ssh://git@git.yourdomain.com:2222/your-username/letzshop-product-import.git
|
||||||
|
git push gitea --all
|
||||||
|
git push gitea --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Gitea built-in migration (code + issues + PRs)
|
||||||
|
|
||||||
|
1. In Gitea, click **+** > **New Migration**.
|
||||||
|
2. Select **GitLab** as the source.
|
||||||
|
3. Enter your GitLab URL and a Personal Access Token.
|
||||||
|
4. Gitea will import the repository, issues, labels, milestones, and merge requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CI/CD — GitLab vs Gitea Actions
|
||||||
|
|
||||||
|
The workflow file lives in `.gitea/workflows/ci.yml` (already created in this repository).
|
||||||
|
|
||||||
|
| GitLab CI (`.gitlab-ci.yml`) | Gitea Actions (`.gitea/workflows/ci.yml`) |
|
||||||
|
|------------------------------|-------------------------------------------|
|
||||||
|
| `stages:` + `stage:` per job | Jobs run in parallel; use `needs:` for ordering |
|
||||||
|
| `services:` (top-level on job) | `services:` nested under each job with `options:` |
|
||||||
|
| `allow_failure: true` | `continue-on-error: true` |
|
||||||
|
| `rules: - if:` | `on:` triggers + `if:` conditionals per job |
|
||||||
|
| `artifacts: paths:` | `actions/upload-artifact@v4` |
|
||||||
|
| `cache: paths:` | `actions/cache@v4` |
|
||||||
|
| `coverage: '/regex/'` | Use coverage action or parse in step |
|
||||||
|
| CI/CD Variables (UI) | Repository **Settings > Secrets** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. CI/CD Secrets
|
||||||
|
|
||||||
|
Configure these in your Gitea repository under **Settings > Actions > Secrets**:
|
||||||
|
|
||||||
|
| Secret | Description | Used by |
|
||||||
|
|--------|-------------|---------|
|
||||||
|
| `SSH_PRIVATE_KEY` | Private key for deployment server | Deploy job (if added) |
|
||||||
|
| `SERVER_HOST` | Production server IP/hostname | Deploy job |
|
||||||
|
| `SERVER_USER` | SSH user on production server | Deploy job |
|
||||||
|
| `SERVER_PATH` | App directory on server | Deploy job |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Pipeline Overview
|
||||||
|
|
||||||
|
The CI pipeline (`.gitea/workflows/ci.yml`) runs:
|
||||||
|
|
||||||
|
```
|
||||||
|
push/PR to master
|
||||||
|
├── ruff (lint)
|
||||||
|
├── pytest (tests + PostgreSQL service)
|
||||||
|
├── architecture (architecture validation)
|
||||||
|
├── dependency-scanning (pip-audit, non-blocking)
|
||||||
|
├── audit (custom audit, non-blocking)
|
||||||
|
└── docs (mkdocs build, master-only, after lint+test pass)
|
||||||
|
```
|
||||||
|
|
||||||
|
All jobs run in parallel except `docs`, which waits for `ruff`, `pytest`, and `architecture` to pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Adding a Deploy Job (Optional)
|
||||||
|
|
||||||
|
To add automated deployment via SSH (similar to the GitLab deploy stage), add this job to `.gitea/workflows/ci.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
|
needs: [ruff, pytest, architecture]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
uses: appleboy/ssh-action@v1
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SERVER_HOST }}
|
||||||
|
username: ${{ secrets.SERVER_USER }}
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
script: |
|
||||||
|
cd ${{ secrets.SERVER_PATH }}
|
||||||
|
git pull origin master
|
||||||
|
source .venv/bin/activate
|
||||||
|
uv sync --frozen
|
||||||
|
python -m alembic upgrade head
|
||||||
|
sudo systemctl restart wizamart
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Firewall Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ufw allow OpenSSH
|
||||||
|
sudo ufw allow 'Nginx Full'
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Maintenance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View Gitea logs
|
||||||
|
docker compose -f ~/gitea/docker-compose.yml logs -f gitea
|
||||||
|
|
||||||
|
# View runner logs
|
||||||
|
docker compose -f ~/gitea/docker-compose.yml logs -f gitea-runner
|
||||||
|
|
||||||
|
# Update Gitea
|
||||||
|
cd ~/gitea
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Backup Gitea data
|
||||||
|
docker run --rm -v gitea-data:/data -v $(pwd):/backup alpine \
|
||||||
|
tar czf /backup/gitea-backup-$(date +%Y%m%d).tar.gz /data
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Removing GitLab (After Migration)
|
||||||
|
|
||||||
|
Once you have verified everything works on Gitea:
|
||||||
|
|
||||||
|
1. Update your local git remote:
|
||||||
|
```bash
|
||||||
|
git remote set-url origin ssh://git@git.yourdomain.com:2222/your-username/letzshop-product-import.git
|
||||||
|
```
|
||||||
|
2. The `.gitlab-ci.yml` file can be removed from the repository.
|
||||||
|
3. Archive or delete the GitLab project.
|
||||||
@@ -160,7 +160,7 @@ nav:
|
|||||||
- Store Operations Expansion: development/migration/store-operations-expansion.md
|
- Store Operations Expansion: development/migration/store-operations-expansion.md
|
||||||
|
|
||||||
- Implementation Plans:
|
- Implementation Plans:
|
||||||
- Module Migration Plan: proposals/module-migration-plan.md
|
- Module Migration Plan: archive/module-migration-plan.md
|
||||||
- Admin Inventory Management: implementation/inventory-admin-migration.md
|
- Admin Inventory Management: implementation/inventory-admin-migration.md
|
||||||
- Admin Notification System: implementation/admin-notification-system.md
|
- Admin Notification System: implementation/admin-notification-system.md
|
||||||
- Letzshop Order Import: implementation/letzshop-order-import-improvements.md
|
- Letzshop Order Import: implementation/letzshop-order-import-improvements.md
|
||||||
@@ -196,6 +196,7 @@ nav:
|
|||||||
- Docker: deployment/docker.md
|
- Docker: deployment/docker.md
|
||||||
- CloudFlare Setup: deployment/cloudflare.md
|
- CloudFlare Setup: deployment/cloudflare.md
|
||||||
- GitLab CI/CD: deployment/gitlab.md
|
- GitLab CI/CD: deployment/gitlab.md
|
||||||
|
- Gitea CI/CD: deployment/gitea.md
|
||||||
- Environment Variables: deployment/environment.md
|
- Environment Variables: deployment/environment.md
|
||||||
- Stripe Integration: deployment/stripe-integration.md
|
- Stripe Integration: deployment/stripe-integration.md
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
267
static/merchant/css/tailwind.css
Normal file
267
static/merchant/css/tailwind.css
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
/* Tailwind CSS v4 - Merchant Portal Styles */
|
||||||
|
/* Configuration is CSS-first in v4 */
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Source paths for content scanning */
|
||||||
|
@source "../../js/**/*.js";
|
||||||
|
@source "../../../app/templates/merchant/**/*.html";
|
||||||
|
@source "../../../app/modules/*/templates/*/merchant/**/*.html";
|
||||||
|
@source "../../../app/modules/core/static/merchant/js/**/*.js";
|
||||||
|
|
||||||
|
/* Custom theme configuration */
|
||||||
|
@theme {
|
||||||
|
/* Custom gray palette (Windmill Dashboard) */
|
||||||
|
--color-gray-50: #f9fafb;
|
||||||
|
--color-gray-100: #f4f5f7;
|
||||||
|
--color-gray-200: #e5e7eb;
|
||||||
|
--color-gray-300: #d5d6d7;
|
||||||
|
--color-gray-400: #9e9e9e;
|
||||||
|
--color-gray-500: #707275;
|
||||||
|
--color-gray-600: #4c4f52;
|
||||||
|
--color-gray-700: #24262d;
|
||||||
|
--color-gray-800: #1a1c23;
|
||||||
|
--color-gray-900: #121317;
|
||||||
|
|
||||||
|
/* Custom purple palette (Windmill Dashboard) */
|
||||||
|
--color-purple-50: #f6f5ff;
|
||||||
|
--color-purple-100: #edebfe;
|
||||||
|
--color-purple-200: #dcd7fe;
|
||||||
|
--color-purple-300: #cabffd;
|
||||||
|
--color-purple-400: #ac94fa;
|
||||||
|
--color-purple-500: #9061f9;
|
||||||
|
--color-purple-600: #7e3af2;
|
||||||
|
--color-purple-700: #6c2bd9;
|
||||||
|
--color-purple-800: #5521b5;
|
||||||
|
--color-purple-900: #4a1d96;
|
||||||
|
|
||||||
|
/* Custom orange palette */
|
||||||
|
--color-orange-50: #fff8f1;
|
||||||
|
--color-orange-100: #feecdc;
|
||||||
|
--color-orange-200: #fcd9bd;
|
||||||
|
--color-orange-300: #fdba8c;
|
||||||
|
--color-orange-400: #ff8a4c;
|
||||||
|
--color-orange-500: #ff5a1f;
|
||||||
|
--color-orange-600: #d03801;
|
||||||
|
--color-orange-700: #b43403;
|
||||||
|
--color-orange-800: #8a2c0d;
|
||||||
|
--color-orange-900: #771d1d;
|
||||||
|
|
||||||
|
/* Custom green palette */
|
||||||
|
--color-green-50: #f3faf7;
|
||||||
|
--color-green-100: #def7ec;
|
||||||
|
--color-green-200: #bcf0da;
|
||||||
|
--color-green-300: #84e1bc;
|
||||||
|
--color-green-400: #31c48d;
|
||||||
|
--color-green-500: #0e9f6e;
|
||||||
|
--color-green-600: #057a55;
|
||||||
|
--color-green-700: #046c4e;
|
||||||
|
--color-green-800: #03543f;
|
||||||
|
--color-green-900: #014737;
|
||||||
|
|
||||||
|
/* Custom red palette */
|
||||||
|
--color-red-50: #fdf2f2;
|
||||||
|
--color-red-100: #fde8e8;
|
||||||
|
--color-red-200: #fbd5d5;
|
||||||
|
--color-red-300: #f8b4b4;
|
||||||
|
--color-red-400: #f98080;
|
||||||
|
--color-red-500: #f05252;
|
||||||
|
--color-red-600: #e02424;
|
||||||
|
--color-red-700: #c81e1e;
|
||||||
|
--color-red-800: #9b1c1c;
|
||||||
|
--color-red-900: #771d1d;
|
||||||
|
|
||||||
|
/* Custom yellow palette */
|
||||||
|
--color-yellow-50: #fdfdea;
|
||||||
|
--color-yellow-100: #fdf6b2;
|
||||||
|
--color-yellow-200: #fce96a;
|
||||||
|
--color-yellow-300: #faca15;
|
||||||
|
--color-yellow-400: #e3a008;
|
||||||
|
--color-yellow-500: #c27803;
|
||||||
|
--color-yellow-600: #9f580a;
|
||||||
|
--color-yellow-700: #8e4b10;
|
||||||
|
--color-yellow-800: #723b13;
|
||||||
|
--color-yellow-900: #633112;
|
||||||
|
|
||||||
|
/* Custom teal palette */
|
||||||
|
--color-teal-50: #edfafa;
|
||||||
|
--color-teal-100: #d5f5f6;
|
||||||
|
--color-teal-200: #afecef;
|
||||||
|
--color-teal-300: #7edce2;
|
||||||
|
--color-teal-400: #16bdca;
|
||||||
|
--color-teal-500: #0694a2;
|
||||||
|
--color-teal-600: #047481;
|
||||||
|
--color-teal-700: #036672;
|
||||||
|
--color-teal-800: #05505c;
|
||||||
|
--color-teal-900: #014451;
|
||||||
|
|
||||||
|
/* Custom blue palette */
|
||||||
|
--color-blue-50: #ebf5ff;
|
||||||
|
--color-blue-100: #e1effe;
|
||||||
|
--color-blue-200: #c3ddfd;
|
||||||
|
--color-blue-300: #a4cafe;
|
||||||
|
--color-blue-400: #76a9fa;
|
||||||
|
--color-blue-500: #3f83f8;
|
||||||
|
--color-blue-600: #1c64f2;
|
||||||
|
--color-blue-700: #1a56db;
|
||||||
|
--color-blue-800: #1e429f;
|
||||||
|
--color-blue-900: #233876;
|
||||||
|
|
||||||
|
/* Custom indigo palette */
|
||||||
|
--color-indigo-50: #f0f5ff;
|
||||||
|
--color-indigo-100: #e5edff;
|
||||||
|
--color-indigo-200: #cddbfe;
|
||||||
|
--color-indigo-300: #b4c6fc;
|
||||||
|
--color-indigo-400: #8da2fb;
|
||||||
|
--color-indigo-500: #6875f5;
|
||||||
|
--color-indigo-600: #5850ec;
|
||||||
|
--color-indigo-700: #5145cd;
|
||||||
|
--color-indigo-800: #42389d;
|
||||||
|
--color-indigo-900: #362f78;
|
||||||
|
|
||||||
|
/* Custom pink palette */
|
||||||
|
--color-pink-50: #fdf2f8;
|
||||||
|
--color-pink-100: #fce8f3;
|
||||||
|
--color-pink-200: #fad1e8;
|
||||||
|
--color-pink-300: #f8b4d9;
|
||||||
|
--color-pink-400: #f17eb8;
|
||||||
|
--color-pink-500: #e74694;
|
||||||
|
--color-pink-600: #d61f69;
|
||||||
|
--color-pink-700: #bf125d;
|
||||||
|
--color-pink-800: #99154b;
|
||||||
|
--color-pink-900: #751a3d;
|
||||||
|
|
||||||
|
/* Cool gray palette */
|
||||||
|
--color-cool-gray-50: #fbfdfe;
|
||||||
|
--color-cool-gray-100: #f1f5f9;
|
||||||
|
--color-cool-gray-200: #e2e8f0;
|
||||||
|
--color-cool-gray-300: #cfd8e3;
|
||||||
|
--color-cool-gray-400: #97a6ba;
|
||||||
|
--color-cool-gray-500: #64748b;
|
||||||
|
--color-cool-gray-600: #475569;
|
||||||
|
--color-cool-gray-700: #364152;
|
||||||
|
--color-cool-gray-800: #27303f;
|
||||||
|
--color-cool-gray-900: #1a202e;
|
||||||
|
|
||||||
|
/* Font family */
|
||||||
|
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
|
||||||
|
/* Custom max-height */
|
||||||
|
--spacing-xl: 36rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode variant - uses class on html element */
|
||||||
|
@variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
/* Custom utilities layer */
|
||||||
|
@layer utilities {
|
||||||
|
/* Hide number input spinners */
|
||||||
|
.no-spinner::-webkit-outer-spin-button,
|
||||||
|
.no-spinner::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.no-spinner {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shadow outline utilities for focus states */
|
||||||
|
.shadow-outline-gray {
|
||||||
|
box-shadow: 0 0 0 3px hsla(220, 9%, 83%, 0.45);
|
||||||
|
}
|
||||||
|
.shadow-outline-purple {
|
||||||
|
box-shadow: 0 0 0 3px hsla(262, 97%, 81%, 0.45);
|
||||||
|
}
|
||||||
|
.shadow-outline-red {
|
||||||
|
box-shadow: 0 0 0 3px hsla(0, 91%, 85%, 0.45);
|
||||||
|
}
|
||||||
|
.shadow-outline-orange {
|
||||||
|
box-shadow: 0 0 0 3px hsla(22, 97%, 77%, 0.45);
|
||||||
|
}
|
||||||
|
.shadow-outline-green {
|
||||||
|
box-shadow: 0 0 0 3px hsla(152, 68%, 70%, 0.45);
|
||||||
|
}
|
||||||
|
.shadow-outline-blue {
|
||||||
|
box-shadow: 0 0 0 3px hsla(215, 96%, 81%, 0.45);
|
||||||
|
}
|
||||||
|
.shadow-outline-yellow {
|
||||||
|
box-shadow: 0 0 0 3px hsla(46, 97%, 65%, 0.45);
|
||||||
|
}
|
||||||
|
.shadow-outline-teal {
|
||||||
|
box-shadow: 0 0 0 3px hsla(182, 68%, 69%, 0.45);
|
||||||
|
}
|
||||||
|
.shadow-outline-indigo {
|
||||||
|
box-shadow: 0 0 0 3px hsla(226, 95%, 85%, 0.45);
|
||||||
|
}
|
||||||
|
.shadow-outline-pink {
|
||||||
|
box-shadow: 0 0 0 3px hsla(330, 89%, 83%, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Component layer for form styles (replaces @tailwindcss/custom-forms) */
|
||||||
|
@layer components {
|
||||||
|
/* Form input styles */
|
||||||
|
.form-input,
|
||||||
|
.form-textarea,
|
||||||
|
.form-select,
|
||||||
|
.form-multiselect {
|
||||||
|
@apply block w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors;
|
||||||
|
@apply focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500;
|
||||||
|
@apply placeholder:text-gray-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode form inputs */
|
||||||
|
.dark .form-input,
|
||||||
|
.dark .form-textarea,
|
||||||
|
.dark .form-select,
|
||||||
|
.dark .form-multiselect {
|
||||||
|
@apply border-gray-600 bg-gray-700 text-gray-200;
|
||||||
|
@apply focus:border-purple-400 focus:ring-purple-400;
|
||||||
|
@apply placeholder:text-gray-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox styles */
|
||||||
|
.form-checkbox {
|
||||||
|
@apply h-4 w-4 rounded border-gray-300 text-purple-600 transition-colors;
|
||||||
|
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .form-checkbox {
|
||||||
|
@apply border-gray-600 bg-gray-700;
|
||||||
|
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radio styles */
|
||||||
|
.form-radio {
|
||||||
|
@apply h-4 w-4 border-gray-300 text-purple-600 transition-colors;
|
||||||
|
@apply focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .form-radio {
|
||||||
|
@apply border-gray-600 bg-gray-700;
|
||||||
|
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base layer for default styles */
|
||||||
|
@layer base {
|
||||||
|
/* Default body styles */
|
||||||
|
body {
|
||||||
|
@apply bg-gray-50 text-gray-900 antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode body */
|
||||||
|
.dark body {
|
||||||
|
@apply bg-gray-900 text-gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus visible for accessibility */
|
||||||
|
:focus-visible {
|
||||||
|
@apply outline-2 outline-offset-2 outline-purple-500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark :focus-visible {
|
||||||
|
@apply outline-purple-400;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
static/merchant/css/tailwind.output.css
Normal file
2
static/merchant/css/tailwind.output.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -59,6 +59,9 @@ class APIClient {
|
|||||||
} else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) {
|
} else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) {
|
||||||
token = customerToken;
|
token = customerToken;
|
||||||
source = 'customer (path-based)';
|
source = 'customer (path-based)';
|
||||||
|
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
|
||||||
|
token = localStorage.getItem('merchant_token');
|
||||||
|
source = 'merchant (path-based)';
|
||||||
} else {
|
} else {
|
||||||
// Default fallback for other paths
|
// Default fallback for other paths
|
||||||
token = adminToken || storeToken || customerToken;
|
token = adminToken || storeToken || customerToken;
|
||||||
@@ -382,6 +385,9 @@ class APIClient {
|
|||||||
} else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) {
|
} else if (currentPath.includes('/shop/') || currentPath.startsWith('/api/v1/shop/')) {
|
||||||
apiLog.info('Clearing customer tokens only');
|
apiLog.info('Clearing customer tokens only');
|
||||||
localStorage.removeItem('customer_token');
|
localStorage.removeItem('customer_token');
|
||||||
|
} else if (currentPath.startsWith('/merchants/') || currentPath.startsWith('/api/v1/merchants/')) {
|
||||||
|
apiLog.info('Clearing merchant tokens only');
|
||||||
|
localStorage.removeItem('merchant_token');
|
||||||
} else {
|
} else {
|
||||||
// Fallback: clear all tokens for unknown paths
|
// Fallback: clear all tokens for unknown paths
|
||||||
apiLog.info('Unknown path context, clearing all tokens');
|
apiLog.info('Unknown path context, clearing all tokens');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,11 +1,11 @@
|
|||||||
/* Tailwind CSS v4 - Shop Frontend Styles */
|
/* Tailwind CSS v4 - Storefront Styles */
|
||||||
/* Configuration is CSS-first in v4 */
|
/* Configuration is CSS-first in v4 */
|
||||||
|
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
/* Source paths for content scanning */
|
/* Source paths for content scanning */
|
||||||
@source "../../js/**/*.js";
|
@source "../../js/**/*.js";
|
||||||
@source "../../../app/templates/shop/**/*.html";
|
@source "../../../app/templates/storefront/**/*.html";
|
||||||
@source "../../../app/templates/shared/**/*.html";
|
@source "../../../app/templates/shared/**/*.html";
|
||||||
|
|
||||||
/* Custom theme configuration */
|
/* Custom theme configuration */
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shop-specific component styles */
|
/* Storefront-specific component styles */
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Form input styles */
|
/* Form input styles */
|
||||||
.form-input,
|
.form-input,
|
||||||
@@ -242,13 +242,13 @@
|
|||||||
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
@apply focus:ring-purple-400 focus:ring-offset-gray-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shop-specific button using CSS variable for store theming */
|
/* Storefront-specific button using CSS variable for store theming */
|
||||||
.btn-shop-primary {
|
.btn-storefront-primary {
|
||||||
background-color: var(--color-primary, #7e3af2);
|
background-color: var(--color-primary, #7e3af2);
|
||||||
@apply text-white px-4 py-2 rounded-lg font-medium transition-all;
|
@apply text-white px-4 py-2 rounded-lg font-medium transition-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-shop-primary:hover {
|
.btn-storefront-primary:hover {
|
||||||
filter: brightness(0.9);
|
filter: brightness(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user