Fill in missing Resend templates + Freescout support links

Closes gaps identified after Phase 3: the four email templates named
in CLAUDE.md's file tree (§5) were only partially built (weekly brief
only). Adds:

- welcome.html, trial_reminder.html, price_alert.html, new_product.html
  — matching the brief.html styling pattern
- services/email_service.py — send_welcome_email(),
  send_trial_reminder_email(), send_price_alert_email(),
  send_new_product_email(), all via the same plain-Jinja2 + Resend
  pattern as brief_service.py (side-effect failures logged, never raised)
- Three new internal routes for n8n/manual-onboarding to call:
  POST /internal/welcome-email/<tenant_id>
  POST /internal/trial-reminder/<tenant_id>
  POST /internal/alert-email/<alert_id> (dispatches to price_alert or
  new_product template by alert_type, marks delivered_email on success)
- alert_repository.mark_email_delivered() to back that last route

Also adds the "Contact support" (Freescout) placeholder link CLAUDE.md
calls for in both the sidebar (visible on every page) and AccountPage,
pointing at support.bettersight.io. The actual Freescout container,
DNS/Pangolin routing, and mailbox are real infra this backend can't
provision — the link is a placeholder until that's deployed.

216 backend tests passing, 95% coverage, 100% on the new email_service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
JasonFraser 2026-07-01 13:32:56 -04:00
parent 11610b8b4a
commit c0a77a1a65
11 changed files with 559 additions and 1 deletions

View File

@ -26,6 +26,7 @@ from services import onboarding_service
from services import billing_service from services import billing_service
from services import auth_service from services import auth_service
from services import analysis_service from services import analysis_service
from services import email_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -777,6 +778,66 @@ def internal_weekly_brief(tenant_id):
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
@api_bp.route('/internal/welcome-email/<tenant_id>', methods=['POST'])
@require_api_key
def internal_welcome_email(tenant_id):
"""Called once during onboarding (manual runbook per §32, or a future signup webhook)."""
tenant = tenant_repository.get_by_id(tenant_id)
if not tenant:
return error_response('not_found', 'Unknown tenant', 404)
data = request.json or {}
sent = email_service.send_welcome_email(tenant['email'], tenant['name'], data.get('template_url'))
return jsonify({'sent': sent})
@api_bp.route('/internal/trial-reminder/<tenant_id>', methods=['POST'])
@require_api_key
def internal_trial_reminder(tenant_id):
"""Called by the n8n daily cron 2 days before trial expiry (CLAUDE.md §21)."""
from datetime import datetime, timezone
tenant = tenant_repository.get_by_id(tenant_id)
if not tenant or not tenant.get('trial_ends_at'):
return error_response('not_found', 'Unknown tenant or no active trial', 404)
trial_end = datetime.fromisoformat(tenant['trial_ends_at'].replace('Z', '+00:00'))
days_left = max(0, (trial_end - datetime.now(timezone.utc)).days)
sent = email_service.send_trial_reminder_email(
tenant['email'], tenant['name'], days_left, trial_end.strftime('%Y-%m-%d')
)
return jsonify({'sent': sent})
@api_bp.route('/internal/alert-email/<alert_id>', methods=['POST'])
@require_api_key
def internal_alert_email(alert_id):
"""
Sends an individual price-change or new-product alert email
called by the n8n alert-creation workflow (CLAUDE.md §15) for
alerts significant enough to warrant an immediate email rather
than waiting for the daily digest.
"""
alert = alert_repository.get_by_id(alert_id)
if not alert:
return error_response('not_found', 'Unknown alert', 404)
tenant = tenant_repository.get_by_id(alert['tenant_id'])
competitor = competitor_repository.get_by_id(alert['competitor_id'])
if not tenant or not competitor:
return error_response('not_found', 'Tenant or competitor no longer exists', 404)
enriched = {**alert, 'competitor_name': competitor['name']}
if alert['alert_type'] == 'price_change':
sent = email_service.send_price_alert_email(tenant['email'], enriched)
elif alert['alert_type'] == 'new_product':
sent = email_service.send_new_product_email(tenant['email'], enriched)
else:
return error_response('unsupported_alert_type', f'No email template for {alert["alert_type"]}', 400)
if sent:
alert_repository.mark_email_delivered(alert_id)
return jsonify({'sent': sent})
@api_bp.route('/internal/monitor-webhook', methods=['POST']) @api_bp.route('/internal/monitor-webhook', methods=['POST'])
@require_api_key @require_api_key
def monitor_webhook(): def monitor_webhook():

View File

@ -25,6 +25,9 @@ class AlertRepository:
def mark_read(self, alert_id): def mark_read(self, alert_id):
return pb_client.update(COLLECTION, alert_id, {'read': True}) return pb_client.update(COLLECTION, alert_id, {'read': True})
def mark_email_delivered(self, alert_id):
return pb_client.update(COLLECTION, alert_id, {'delivered_email': True})
class MockAlertRepository: class MockAlertRepository:
"""In-memory stand-in for AlertRepository.""" """In-memory stand-in for AlertRepository."""
@ -55,5 +58,11 @@ class MockAlertRepository:
self._records[alert_id]['read'] = True self._records[alert_id]['read'] = True
return self._records[alert_id] return self._records[alert_id]
def mark_email_delivered(self, alert_id):
if alert_id not in self._records:
return None
self._records[alert_id]['delivered_email'] = True
return self._records[alert_id]
alert_repository = MockAlertRepository() if USE_MOCK_REPOSITORIES else AlertRepository() alert_repository = MockAlertRepository() if USE_MOCK_REPOSITORIES else AlertRepository()

View File

@ -0,0 +1,105 @@
import os
import logging
from datetime import datetime
from jinja2 import Environment, FileSystemLoader, select_autoescape
logger = logging.getLogger(__name__)
RESEND_API_KEY = os.getenv('RESEND_API_KEY')
TEMPLATES_DIR = os.path.join(os.path.dirname(__file__), '..', 'templates', 'emails')
# Same reasoning as brief_service.py: plain Jinja2, not flask.render_template,
# so this module has no dependency on an active Flask app context.
_jinja_env = Environment(
loader=FileSystemLoader(TEMPLATES_DIR),
autoescape=select_autoescape(['html'])
)
def _send(template_name, to_email, subject, context, from_address='alerts@bettersight.io'):
"""
Renders a Jinja2 template and sends it via Resend. A failed send is
a side-effect failure logged only, never raised into the
caller's primary operation (signup completing, an alert being
created, etc.) per CLAUDE.md §18 resilience rules.
Data flow:
template_name + context Jinja2 render Resend Emails.send()
True on success / False on failure (logged)
"""
try:
import resend
resend.api_key = RESEND_API_KEY
template = _jinja_env.get_template(template_name)
html = template.render(**context)
resend.Emails.send({
'from': f'Bettersight <{from_address}>',
'to': to_email,
'subject': subject,
'html': html,
})
return True
except Exception as e:
logger.error(f'Failed to send {template_name} to {to_email}: {str(e)}')
return False
def send_welcome_email(to_email, tenant_name, template_url=None):
"""
Sends the branded welcome email on signup with a Sheet template
download link. Data flow: tenant email + name + template URL
welcome.html rendered Resend API inbox (CLAUDE.md §21).
"""
return _send('welcome.html', to_email, 'Welcome to Bettersight — you\'re all set', {
'name': tenant_name,
'template_url': template_url,
})
def send_trial_reminder_email(to_email, tenant_name, days_left, trial_end_date):
"""
Sends the "trial ends in N days" reminder fired 2 days before
expiry per CLAUDE.md §21 Trial Periods ("n8n fires trial_reminder
email (2 days before expiry)").
"""
return _send('trial_reminder.html', to_email, f'Your Bettersight trial ends in {days_left} days', {
'name': tenant_name,
'days_left': days_left,
'trial_end_date': trial_end_date,
})
def send_price_alert_email(to_email, alert):
"""
Sends an individual price-change alert email. `alert` is an
alerts-collection record (competitor name resolved by the caller
since alerts only store competitor_id).
Data flow:
alert dict price_alert.html rendered Resend API inbox
"""
previous_price = None
if alert.get('change_amount') is not None and alert.get('majority_price_usd') is not None:
previous_price = alert['majority_price_usd'] - alert['change_amount']
return _send('price_alert.html', to_email, f'Price alert — {alert.get("competitor_name", "a competitor")}', {
'competitor_name': alert.get('competitor_name'),
'trip_name': alert.get('trip_name'),
'previous_price': previous_price,
'current_price': alert.get('majority_price_usd'),
'change_percent': alert.get('change_percent'),
})
def send_new_product_email(to_email, alert):
"""
Sends an individual new-product alert email. Data flow: alert dict
new_product.html rendered Resend API inbox.
"""
return _send('new_product.html', to_email, f'New product — {alert.get("competitor_name", "a competitor")}', {
'competitor_name': alert.get('competitor_name'),
'trip_name': alert.get('trip_name'),
'destination': alert.get('destination'),
'duration_days': alert.get('duration_days'),
'price_usd': alert.get('majority_price_usd'),
})

View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bettersight new product alert</title>
</head>
<body style="margin:0;padding:0;background:#EEF1F6;font-family:Arial,sans-serif;color:#0D1526;">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#FFFFFF;">
<tr>
<td style="padding:24px 28px;border-bottom:1px solid #E5E9F0;">
<span style="font-size:18px;font-weight:800;color:#0D1526;">Better<span style="color:#00D4FF;">sight</span></span>
<div style="font-size:13px;color:#6B7A99;margin-top:4px;">New product alert</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<h1 style="font-size:16px;margin:0 0 14px;">
{{ competitor_name }} added a new product
<span style="background:#E0F8FF;color:#0284C7;font-size:11px;font-weight:700;padding:2px 8px;border-radius:20px;">NEW</span>
</h1>
<p style="font-size:13.5px;color:#2D3A52;line-height:1.7;margin:0 0 16px;">
<strong>{{ trip_name }}</strong><br>
{{ destination }} · {{ duration_days }} days
{% if price_usd %} · ${{ price_usd }} USD{% endif %}
</p>
<a href="https://app.bettersight.io/activity" style="display:inline-block;background:linear-gradient(135deg,#1B8EF8,#0B7AE8);color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:10px 20px;border-radius:9px;">
View in dashboard →
</a>
</td>
</tr>
<tr>
<td style="padding:16px 28px;font-size:11px;color:#9BA8C0;border-top:1px solid #E5E9F0;">
Bettersight · Better sight, better moves.
<br>
<a href="https://app.bettersight.io/account" style="color:#9BA8C0;">Manage notification preferences</a>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bettersight price alert</title>
</head>
<body style="margin:0;padding:0;background:#EEF1F6;font-family:Arial,sans-serif;color:#0D1526;">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#FFFFFF;">
<tr>
<td style="padding:24px 28px;border-bottom:1px solid #E5E9F0;">
<span style="font-size:18px;font-weight:800;color:#0D1526;">Better<span style="color:#00D4FF;">sight</span></span>
<div style="font-size:13px;color:#6B7A99;margin-top:4px;">Price alert</div>
</td>
</tr>
<tr>
<td style="padding:28px;">
<h1 style="font-size:16px;margin:0 0 14px;">
{{ competitor_name }}
{% if change_percent is not none and change_percent < 0 %}dropped{% else %}raised{% endif %}
{{ trip_name }}
{% if change_percent is not none %}{{ change_percent }}%{% endif %}
</h1>
<table cellpadding="0" cellspacing="0" style="font-size:13px;margin-bottom:16px;">
<tr>
<td style="padding:4px 12px 4px 0;color:#6B7A99;">Previous</td>
<td style="padding:4px 0;font-weight:700;">${{ previous_price }}</td>
</tr>
<tr>
<td style="padding:4px 12px 4px 0;color:#6B7A99;">Current</td>
<td style="padding:4px 0;font-weight:700;color:{{ '#F04438' if change_percent is not none and change_percent < 0 else '#059669' }};">
${{ current_price }}
</td>
</tr>
</table>
<a href="https://app.bettersight.io/activity" style="display:inline-block;background:linear-gradient(135deg,#1B8EF8,#0B7AE8);color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:10px 20px;border-radius:9px;">
View in dashboard →
</a>
</td>
</tr>
<tr>
<td style="padding:16px 28px;font-size:11px;color:#9BA8C0;border-top:1px solid #E5E9F0;">
Bettersight · Better sight, better moves.
<br>
<a href="https://app.bettersight.io/account" style="color:#9BA8C0;">Manage notification preferences</a>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Your Bettersight trial is ending soon</title>
</head>
<body style="margin:0;padding:0;background:#EEF1F6;font-family:Arial,sans-serif;color:#0D1526;">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#FFFFFF;">
<tr>
<td style="padding:24px 28px;border-bottom:1px solid #E5E9F0;">
<span style="font-size:18px;font-weight:800;color:#0D1526;">Better<span style="color:#00D4FF;">sight</span></span>
</td>
</tr>
<tr>
<td style="padding:28px;">
<h1 style="font-size:18px;margin:0 0 14px;">
Your trial ends in {{ days_left }} day{{ '' if days_left == 1 else 's' }}
</h1>
<p style="font-size:13.5px;color:#2D3A52;line-height:1.7;margin:0 0 18px;">
Hi {{ name }},<br><br>
Your Bettersight trial ends on {{ trial_end_date }}. Your subscription
will continue automatically on that date unless you cancel.
</p>
<p style="font-size:13px;color:#6B7A99;line-height:1.6;margin:0 0 20px;">
If you've hit any friction at all, reply to this email — we're happy
to help you get the most out of Bettersight before the trial ends.
</p>
<a href="https://app.bettersight.io/account" style="display:inline-block;background:linear-gradient(135deg,#1B8EF8,#0B7AE8);color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:10px 20px;border-radius:9px;">
Manage your subscription →
</a>
</td>
</tr>
<tr>
<td style="padding:16px 28px;font-size:11px;color:#9BA8C0;border-top:1px solid #E5E9F0;">
Bettersight · Better sight, better moves.
<br>
Questions? Reply to this email or visit
<a href="https://support.bettersight.io" style="color:#9BA8C0;">support.bettersight.io</a>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Welcome to Bettersight</title>
</head>
<body style="margin:0;padding:0;background:#EEF1F6;font-family:Arial,sans-serif;color:#0D1526;">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;background:#FFFFFF;">
<tr>
<td style="padding:24px 28px;border-bottom:1px solid #E5E9F0;">
<span style="font-size:18px;font-weight:800;color:#0D1526;">Better<span style="color:#00D4FF;">sight</span></span>
</td>
</tr>
<tr>
<td style="padding:28px;">
<h1 style="font-size:18px;margin:0 0 14px;">Welcome to Bettersight — you're all set</h1>
<p style="font-size:13.5px;color:#2D3A52;line-height:1.7;margin:0 0 18px;">
Hi {{ name }},<br><br>
You're set up and ready to go. Here's how to get started in 5 minutes:
</p>
<ol style="font-size:13.5px;color:#2D3A52;line-height:2;margin:0 0 20px;padding-left:20px;">
<li>Sign in at <a href="https://app.bettersight.io" style="color:#1B8EF8;">app.bettersight.io</a></li>
<li>Add your first competitor (Competitors → + Add Competitor)</li>
<li>Run your first analysis (Analyse → describe your trip)</li>
<li>Push the results to your Google Sheet</li>
</ol>
<p style="font-size:13px;color:#6B7A99;line-height:1.6;margin:0 0 20px;">
The guided tour will walk you through each step when you first log in.
</p>
{% if template_url %}
<a href="{{ template_url }}" style="display:inline-block;background:linear-gradient(135deg,#1B8EF8,#0B7AE8);color:#fff;text-decoration:none;font-size:13px;font-weight:600;padding:10px 20px;border-radius:9px;">
Download your Sheet template →
</a>
{% endif %}
</td>
</tr>
<tr>
<td style="padding:16px 28px;font-size:11px;color:#9BA8C0;border-top:1px solid #E5E9F0;">
Bettersight · Better sight, better moves.
<br>
Questions? Reply to this email or visit
<a href="https://support.bettersight.io" style="color:#9BA8C0;">support.bettersight.io</a>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,84 @@
import sys
import types
from services import email_service
def _install_fake_resend(monkeypatch, raise_error=False):
fake = types.ModuleType('resend')
fake.api_key = None
calls = []
class _Emails:
@staticmethod
def send(payload):
if raise_error:
raise RuntimeError('resend api down')
calls.append(payload)
fake.Emails = _Emails
monkeypatch.setitem(sys.modules, 'resend', fake)
return calls
def test_send_welcome_email_renders_and_sends(monkeypatch):
calls = _install_fake_resend(monkeypatch)
result = email_service.send_welcome_email(
'pm@gadventures.com', 'G Adventures', template_url='https://sheets.google.com/template'
)
assert result is True
assert len(calls) == 1
assert calls[0]['to'] == 'pm@gadventures.com'
assert 'G Adventures' in calls[0]['html']
assert 'template' in calls[0]['html']
def test_send_welcome_email_without_template_url(monkeypatch):
calls = _install_fake_resend(monkeypatch)
result = email_service.send_welcome_email('pm@gadventures.com', 'G Adventures')
assert result is True
assert len(calls) == 1
def test_send_trial_reminder_email(monkeypatch):
calls = _install_fake_resend(monkeypatch)
result = email_service.send_trial_reminder_email('pm@gadventures.com', 'G Adventures', 2, '2026-07-14')
assert result is True
assert '2' in calls[0]['subject']
assert '2026-07-14' in calls[0]['html']
def test_send_price_alert_email_computes_previous_price(monkeypatch):
calls = _install_fake_resend(monkeypatch)
result = email_service.send_price_alert_email('pm@gadventures.com', {
'competitor_name': 'Intrepid Travel', 'trip_name': 'Peru Classic',
'majority_price_usd': 2190, 'change_amount': -300, 'change_percent': -12,
})
assert result is True
assert 'Intrepid Travel' in calls[0]['subject']
assert '2490' in calls[0]['html'] # previous = 2190 - (-300)
assert '2190' in calls[0]['html']
def test_send_new_product_email(monkeypatch):
calls = _install_fake_resend(monkeypatch)
result = email_service.send_new_product_email('pm@gadventures.com', {
'competitor_name': 'Flash Pack', 'trip_name': 'Jordan Desert Explorer',
'destination': 'Jordan', 'duration_days': 8, 'majority_price_usd': 3200,
})
assert result is True
assert 'Flash Pack' in calls[0]['subject']
assert 'Jordan Desert Explorer' in calls[0]['html']
def test_send_returns_false_and_logs_on_failure(monkeypatch):
_install_fake_resend(monkeypatch, raise_error=True)
result = email_service.send_welcome_email('pm@gadventures.com', 'G Adventures')
assert result is False

View File

@ -242,6 +242,102 @@ def test_internal_weekly_brief(client, api_headers, monkeypatch):
assert called == ['t1'] assert called == ['t1']
def test_internal_welcome_email_unknown_tenant_404(client, api_headers):
response = client.post('/internal/welcome-email/does-not-exist', headers=api_headers)
assert response.status_code == 404
def test_internal_welcome_email_sends(client, api_headers, monkeypatch):
tenant = _make_tenant()
monkeypatch.setattr('api.email_service.send_welcome_email', lambda email, name, url=None: True)
response = client.post(
f'/internal/welcome-email/{tenant["id"]}', json={'template_url': 'https://sheets/x'}, headers=api_headers
)
assert response.status_code == 200
assert response.json['sent'] is True
def test_internal_trial_reminder_no_active_trial_404(client, api_headers):
tenant = _make_tenant(trial_ends_at=None)
response = client.post(f'/internal/trial-reminder/{tenant["id"]}', headers=api_headers)
assert response.status_code == 404
def test_internal_trial_reminder_sends(client, api_headers, monkeypatch):
from datetime import datetime, timedelta, timezone
tenant = _make_tenant(trial_ends_at=(datetime.now(timezone.utc) + timedelta(days=2)).isoformat())
captured = {}
monkeypatch.setattr(
'api.email_service.send_trial_reminder_email',
lambda email, name, days_left, trial_end_date: captured.update(days_left=days_left) or True
)
response = client.post(f'/internal/trial-reminder/{tenant["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['sent'] is True
assert captured['days_left'] in (1, 2) # datetime.days truncation can land on either
def test_internal_alert_email_unknown_alert_404(client, api_headers):
response = client.post('/internal/alert-email/does-not-exist', headers=api_headers)
assert response.status_code == 404
def test_internal_alert_email_unsupported_type_400(client, api_headers):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
alert = alert_repository.create({
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'new_comparable', 'message': 'x',
})
response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers)
assert response.status_code == 400
assert response.json['code'] == 'unsupported_alert_type'
def test_internal_alert_email_sends_price_change_and_marks_delivered(client, api_headers, monkeypatch):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Intrepid', 'website': 'https://intrepidtravel.com',
'catalogue_url': 'https://intrepidtravel.com/adventures', 'primary_market': 'GB', 'active': True,
})
alert = alert_repository.create({
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'price_change',
'message': 'Price dropped', 'change_percent': -12,
})
monkeypatch.setattr('api.email_service.send_price_alert_email', lambda email, alert_data: True)
response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['sent'] is True
assert alert_repository.get_by_id(alert['id'])['delivered_email'] is True
def test_internal_alert_email_sends_new_product(client, api_headers, monkeypatch):
tenant = _make_tenant()
competitor = competitor_repository.create({
'tenant_id': tenant['id'], 'name': 'Flash Pack', 'website': 'https://flashpack.com',
'catalogue_url': 'https://flashpack.com/adventures', 'primary_market': 'US', 'active': True,
})
alert = alert_repository.create({
'tenant_id': tenant['id'], 'competitor_id': competitor['id'], 'alert_type': 'new_product', 'message': 'New trip',
})
monkeypatch.setattr('api.email_service.send_new_product_email', lambda email, alert_data: True)
response = client.post(f'/internal/alert-email/{alert["id"]}', headers=api_headers)
assert response.status_code == 200
assert response.json['sent'] is True
def test_internal_monitor_webhook_flags_competitor(client, api_headers): def test_internal_monitor_webhook_flags_competitor(client, api_headers):
tenant = _make_tenant() tenant = _make_tenant()
competitor = competitor_repository.create({ competitor = competitor_repository.create({

View File

@ -63,6 +63,14 @@ const planLabel = computed(() => {
</nav> </nav>
<div class="sb-foot"> <div class="sb-foot">
<a
href="https://support.bettersight.io"
target="_blank"
rel="noopener"
style="display:block;padding:8px 10px 4px;font-size:11px;color:rgba(255,255,255,.38);text-decoration:none;"
>
Contact support
</a>
<RouterLink to="/account" class="sb-user"> <RouterLink to="/account" class="sb-user">
<div class="sb-av">{{ initials }}</div> <div class="sb-av">{{ initials }}</div>
<div style="flex:1;min-width:0;"> <div style="flex:1;min-width:0;">

View File

@ -124,7 +124,7 @@ function replayTour() {
@remove="removeSeat" @remove="removeSeat"
/> />
<div class="card" style="padding:18px 20px;"> <div class="card" style="padding:18px 20px;display:flex;flex-direction:column;gap:10px;">
<p <p
v-if="authStore.onboarding.tour_skipped || authStore.onboarding.tour_completed" v-if="authStore.onboarding.tour_skipped || authStore.onboarding.tour_completed"
style="font-size:12.5px;color:var(--blue-vivid);cursor:pointer;margin:0;" style="font-size:12.5px;color:var(--blue-vivid);cursor:pointer;margin:0;"
@ -132,6 +132,14 @@ function replayTour() {
> >
Take the guided tour again Take the guided tour again
</p> </p>
<a
href="https://support.bettersight.io"
target="_blank"
rel="noopener"
style="font-size:12.5px;color:var(--t3);text-decoration:none;"
>
Contact support
</a>
</div> </div>
</div> </div>
</div> </div>