feat(oauth): Add OAuth integration UI, API clients, and audit enrichment (Phase 3)
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- Company settings page with 4 OAuth cards (GBP, Search Console, Facebook, Instagram) - 3 API service clients: GBP Management, Search Console, Facebook Graph - OAuth enrichment in GBP audit (owner responses, posts), social media (FB/IG Graph API), and SEO prompt (Search Console data) - Fix OAuth callback redirects to point to company settings page - All integrations have graceful fallback when no OAuth credentials configured Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
edcba4b178
commit
70e40d133b
@ -156,6 +156,25 @@ def _collect_seo_data(db, company) -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"CrUX error for {company.website}: {e}")
|
logger.warning(f"CrUX error for {company.website}: {e}")
|
||||||
|
|
||||||
|
# Search Console data via OAuth (if available)
|
||||||
|
search_console_data = {}
|
||||||
|
try:
|
||||||
|
from oauth_service import OAuthService
|
||||||
|
from search_console_service import SearchConsoleService
|
||||||
|
|
||||||
|
oauth = OAuthService()
|
||||||
|
gsc_token = oauth.get_valid_token(db, company.id, 'google', 'search_console')
|
||||||
|
if gsc_token and company.website:
|
||||||
|
gsc = SearchConsoleService(gsc_token)
|
||||||
|
analytics = gsc.get_search_analytics(company.website, days=28)
|
||||||
|
if analytics:
|
||||||
|
search_console_data = analytics
|
||||||
|
logger.info(f"Search Console data for company {company.id}: {analytics.get('clicks', 0)} clicks")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Search Console data collection failed: {e}")
|
||||||
|
|
||||||
# Persist live-collected data to DB for dashboard display
|
# Persist live-collected data to DB for dashboard display
|
||||||
try:
|
try:
|
||||||
if security_headers:
|
if security_headers:
|
||||||
@ -262,6 +281,13 @@ def _collect_seo_data(db, company) -> dict:
|
|||||||
'crux_lcp_good_pct': crux_data.get('crux_lcp_ms_good_pct'),
|
'crux_lcp_good_pct': crux_data.get('crux_lcp_ms_good_pct'),
|
||||||
'crux_inp_good_pct': crux_data.get('crux_inp_ms_good_pct'),
|
'crux_inp_good_pct': crux_data.get('crux_inp_ms_good_pct'),
|
||||||
'crux_period_end': crux_data.get('crux_period_end'),
|
'crux_period_end': crux_data.get('crux_period_end'),
|
||||||
|
# Search Console (OAuth)
|
||||||
|
'gsc_clicks': search_console_data.get('clicks'),
|
||||||
|
'gsc_impressions': search_console_data.get('impressions'),
|
||||||
|
'gsc_ctr': search_console_data.get('ctr'),
|
||||||
|
'gsc_avg_position': search_console_data.get('position'),
|
||||||
|
'gsc_top_queries': search_console_data.get('top_queries', []),
|
||||||
|
'gsc_period_days': search_console_data.get('period_days', 28),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -484,6 +510,13 @@ Dane strukturalne:
|
|||||||
- Pola LocalBusiness Schema: {data.get('local_business_schema_fields', 'brak danych')}
|
- Pola LocalBusiness Schema: {data.get('local_business_schema_fields', 'brak danych')}
|
||||||
- Język strony (html lang): {data.get('html_lang', 'brak')}
|
- Język strony (html lang): {data.get('html_lang', 'brak')}
|
||||||
|
|
||||||
|
Search Console (dane z Google Search Console, ostatnie {data.get('gsc_period_days', 28)} dni):
|
||||||
|
- Kliknięcia: {data.get('gsc_clicks', 'brak danych (wymaga połączenia OAuth)')}
|
||||||
|
- Wyświetlenia: {data.get('gsc_impressions', 'brak danych')}
|
||||||
|
- CTR: {data.get('gsc_ctr', 'brak danych')}%
|
||||||
|
- Średnia pozycja: {data.get('gsc_avg_position', 'brak danych')}
|
||||||
|
- Top zapytania: {', '.join(q.get('query', '') for q in (data.get('gsc_top_queries') or [])[:5]) or 'brak danych'}
|
||||||
|
|
||||||
Social & Analytics:
|
Social & Analytics:
|
||||||
- Open Graph: {'tak' if data.get('has_og_tags') else 'NIE'}
|
- Open Graph: {'tak' if data.get('has_og_tags') else 'NIE'}
|
||||||
- Twitter Cards: {'tak' if data.get('has_twitter_cards') else 'NIE'}
|
- Twitter Cards: {'tak' if data.get('has_twitter_cards') else 'NIE'}
|
||||||
|
|||||||
@ -599,3 +599,35 @@ def admin_companies_export():
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/companies/<int:company_id>/settings')
|
||||||
|
@login_required
|
||||||
|
@role_required(SystemRole.OFFICE_MANAGER)
|
||||||
|
def company_settings(company_id):
|
||||||
|
"""Company settings page with OAuth integrations UI."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
company = db.query(Company).filter(Company.id == company_id).first()
|
||||||
|
if not company:
|
||||||
|
flash('Firma nie istnieje', 'error')
|
||||||
|
return redirect(url_for('admin.admin_companies'))
|
||||||
|
|
||||||
|
from oauth_service import OAuthService
|
||||||
|
oauth = OAuthService()
|
||||||
|
connections = oauth.get_connected_services(db, company_id)
|
||||||
|
|
||||||
|
# Check if OAuth credentials are configured
|
||||||
|
oauth_available = {
|
||||||
|
'google': bool(oauth.google_client_id),
|
||||||
|
'meta': bool(oauth.meta_app_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
'admin/company_settings.html',
|
||||||
|
company=company,
|
||||||
|
connections=connections,
|
||||||
|
oauth_available=oauth_available,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
@ -68,18 +68,33 @@ def oauth_callback(provider):
|
|||||||
state = request.args.get('state')
|
state = request.args.get('state')
|
||||||
error = request.args.get('error')
|
error = request.args.get('error')
|
||||||
|
|
||||||
|
# Parse state early to get company_id for redirects
|
||||||
|
# State format: company_id:user_id:service:random
|
||||||
|
state_company_id = None
|
||||||
|
if state:
|
||||||
|
try:
|
||||||
|
state_company_id = int(state.split(':')[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def settings_redirect(params):
|
||||||
|
"""Redirect to company settings page with query params."""
|
||||||
|
if state_company_id:
|
||||||
|
return redirect(f'/admin/companies/{state_company_id}/settings?{params}')
|
||||||
|
return redirect(f'/admin/companies?{params}')
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
logger.warning(f"OAuth error from {provider}: {error}")
|
logger.warning(f"OAuth error from {provider}: {error}")
|
||||||
return redirect(f'/admin/company?oauth_error={error}')
|
return settings_redirect(f'oauth_error={error}')
|
||||||
|
|
||||||
if not code or not state:
|
if not code or not state:
|
||||||
return redirect('/admin/company?oauth_error=missing_params')
|
return settings_redirect('oauth_error=missing_params')
|
||||||
|
|
||||||
# Validate state
|
# Validate state
|
||||||
saved_state = session.pop('oauth_state', None)
|
saved_state = session.pop('oauth_state', None)
|
||||||
if not saved_state or saved_state != state:
|
if not saved_state or saved_state != state:
|
||||||
logger.warning(f"OAuth state mismatch for {provider}")
|
logger.warning(f"OAuth state mismatch for {provider}")
|
||||||
return redirect('/admin/company?oauth_error=invalid_state')
|
return settings_redirect('oauth_error=invalid_state')
|
||||||
|
|
||||||
# Parse state: company_id:user_id:service:random
|
# Parse state: company_id:user_id:service:random
|
||||||
try:
|
try:
|
||||||
@ -88,18 +103,18 @@ def oauth_callback(provider):
|
|||||||
user_id = int(parts[1])
|
user_id = int(parts[1])
|
||||||
service = parts[2]
|
service = parts[2]
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
return redirect('/admin/company?oauth_error=invalid_state_format')
|
return settings_redirect('oauth_error=invalid_state_format')
|
||||||
|
|
||||||
# Verify user owns this company
|
# Verify user owns this company
|
||||||
if current_user.id != user_id or current_user.company_id != company_id:
|
if current_user.id != user_id or current_user.company_id != company_id:
|
||||||
return redirect('/admin/company?oauth_error=unauthorized')
|
return redirect(f'/admin/companies/{company_id}/settings?oauth_error=unauthorized')
|
||||||
|
|
||||||
# Exchange code for token
|
# Exchange code for token
|
||||||
oauth = OAuthService()
|
oauth = OAuthService()
|
||||||
token_data = oauth.exchange_code(provider, code)
|
token_data = oauth.exchange_code(provider, code)
|
||||||
|
|
||||||
if not token_data:
|
if not token_data:
|
||||||
return redirect('/admin/company?oauth_error=token_exchange_failed')
|
return redirect(f'/admin/companies/{company_id}/settings?oauth_error=token_exchange_failed')
|
||||||
|
|
||||||
# Save token
|
# Save token
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@ -107,9 +122,9 @@ def oauth_callback(provider):
|
|||||||
success = oauth.save_token(db, company_id, user_id, provider, service, token_data)
|
success = oauth.save_token(db, company_id, user_id, provider, service, token_data)
|
||||||
if success:
|
if success:
|
||||||
logger.info(f"OAuth connected: {provider}/{service} for company {company_id} by user {user_id}")
|
logger.info(f"OAuth connected: {provider}/{service} for company {company_id} by user {user_id}")
|
||||||
return redirect(f'/admin/company?oauth_success={provider}/{service}')
|
return redirect(f'/admin/companies/{company_id}/settings?oauth_success={provider}/{service}')
|
||||||
else:
|
else:
|
||||||
return redirect('/admin/company?oauth_error=save_failed')
|
return redirect(f'/admin/companies/{company_id}/settings?oauth_error=save_failed')
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
@ -182,3 +197,36 @@ def oauth_disconnect(provider, service):
|
|||||||
return jsonify({'success': False, 'error': 'Nie znaleziono połączenia'}), 404
|
return jsonify({'success': False, 'error': 'Nie znaleziono połączenia'}), 404
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/oauth/google/discover-locations', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def oauth_discover_gbp_locations():
|
||||||
|
"""Auto-discover GBP locations after OAuth connection."""
|
||||||
|
if not current_user.company_id:
|
||||||
|
return jsonify({'success': False, 'error': 'Brak firmy'}), 403
|
||||||
|
|
||||||
|
from oauth_service import OAuthService
|
||||||
|
oauth = OAuthService()
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
token = oauth.get_valid_token(db, current_user.company_id, 'google', 'gbp')
|
||||||
|
if not token:
|
||||||
|
return jsonify({'success': False, 'error': 'Brak połączenia GBP'}), 404
|
||||||
|
|
||||||
|
try:
|
||||||
|
from gbp_management_service import GBPManagementService
|
||||||
|
gbp = GBPManagementService(token)
|
||||||
|
accounts = gbp.list_accounts()
|
||||||
|
locations = []
|
||||||
|
for acc in accounts:
|
||||||
|
acc_locations = gbp.list_locations(acc.get('name', ''))
|
||||||
|
locations.extend(acc_locations)
|
||||||
|
return jsonify({'success': True, 'locations': locations})
|
||||||
|
except ImportError:
|
||||||
|
return jsonify({'success': False, 'error': 'Serwis GBP Management niedostępny'}), 503
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GBP discover locations error: {e}")
|
||||||
|
return jsonify({'success': False, 'error': 'Błąd podczas wyszukiwania lokalizacji'}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|||||||
123
facebook_graph_service.py
Normal file
123
facebook_graph_service.py
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Facebook + Instagram Graph API Client
|
||||||
|
======================================
|
||||||
|
|
||||||
|
Uses OAuth 2.0 page tokens to access Facebook Page and Instagram Business data.
|
||||||
|
|
||||||
|
API docs: https://developers.facebook.com/docs/graph-api/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FacebookGraphService:
|
||||||
|
"""Facebook + Instagram Graph API client."""
|
||||||
|
|
||||||
|
BASE_URL = "https://graph.facebook.com/v21.0"
|
||||||
|
|
||||||
|
def __init__(self, access_token: str):
|
||||||
|
self.access_token = access_token
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.timeout = 15
|
||||||
|
|
||||||
|
def _get(self, endpoint: str, params: dict = None) -> Optional[Dict]:
|
||||||
|
"""Make authenticated GET request."""
|
||||||
|
params = params or {}
|
||||||
|
params['access_token'] = self.access_token
|
||||||
|
try:
|
||||||
|
resp = self.session.get(f"{self.BASE_URL}/{endpoint}", params=params)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Facebook API {endpoint} failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_managed_pages(self) -> List[Dict]:
|
||||||
|
"""Get Facebook pages managed by the authenticated user."""
|
||||||
|
data = self._get('me/accounts', {'fields': 'id,name,category,access_token,fan_count'})
|
||||||
|
return data.get('data', []) if data else []
|
||||||
|
|
||||||
|
def get_page_info(self, page_id: str) -> Optional[Dict]:
|
||||||
|
"""Get detailed page information."""
|
||||||
|
return self._get(page_id, {
|
||||||
|
'fields': 'id,name,fan_count,category,link,about,website,phone,single_line_address,followers_count'
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_page_insights(self, page_id: str, days: int = 28) -> Dict:
|
||||||
|
"""Get page insights (impressions, engaged users, reactions).
|
||||||
|
|
||||||
|
Note: Requires page access token, not user access token.
|
||||||
|
The page token should be stored during OAuth connection.
|
||||||
|
"""
|
||||||
|
since = datetime.now() - timedelta(days=days)
|
||||||
|
until = datetime.now()
|
||||||
|
|
||||||
|
metrics = 'page_impressions,page_engaged_users,page_fans,page_views_total'
|
||||||
|
data = self._get(f'{page_id}/insights', {
|
||||||
|
'metric': metrics,
|
||||||
|
'period': 'day',
|
||||||
|
'since': int(since.timestamp()),
|
||||||
|
'until': int(until.timestamp()),
|
||||||
|
})
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for metric in data.get('data', []):
|
||||||
|
name = metric.get('name', '')
|
||||||
|
values = metric.get('values', [])
|
||||||
|
if values:
|
||||||
|
total = sum(v.get('value', 0) for v in values if isinstance(v.get('value'), (int, float)))
|
||||||
|
result[name] = total
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_instagram_account(self, page_id: str) -> Optional[str]:
|
||||||
|
"""Get linked Instagram Business account ID from a Facebook Page."""
|
||||||
|
data = self._get(page_id, {'fields': 'instagram_business_account'})
|
||||||
|
if data and 'instagram_business_account' in data:
|
||||||
|
return data['instagram_business_account'].get('id')
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_ig_media_insights(self, ig_account_id: str, days: int = 28) -> Dict:
|
||||||
|
"""Get Instagram account insights.
|
||||||
|
|
||||||
|
Returns follower_count, media_count, and recent media engagement.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# Basic account info
|
||||||
|
account_data = self._get(ig_account_id, {
|
||||||
|
'fields': 'followers_count,media_count,username,biography'
|
||||||
|
})
|
||||||
|
if account_data:
|
||||||
|
result['followers_count'] = account_data.get('followers_count', 0)
|
||||||
|
result['media_count'] = account_data.get('media_count', 0)
|
||||||
|
result['username'] = account_data.get('username', '')
|
||||||
|
|
||||||
|
# Account insights (reach, impressions)
|
||||||
|
since = datetime.now() - timedelta(days=days)
|
||||||
|
until = datetime.now()
|
||||||
|
insights_data = self._get(f'{ig_account_id}/insights', {
|
||||||
|
'metric': 'impressions,reach,follower_count',
|
||||||
|
'period': 'day',
|
||||||
|
'since': int(since.timestamp()),
|
||||||
|
'until': int(until.timestamp()),
|
||||||
|
})
|
||||||
|
|
||||||
|
if insights_data:
|
||||||
|
for metric in insights_data.get('data', []):
|
||||||
|
name = metric.get('name', '')
|
||||||
|
values = metric.get('values', [])
|
||||||
|
if values:
|
||||||
|
total = sum(v.get('value', 0) for v in values if isinstance(v.get('value'), (int, float)))
|
||||||
|
result[f'ig_{name}_total'] = total
|
||||||
|
|
||||||
|
return result
|
||||||
@ -23,7 +23,7 @@ from typing import Dict, List, Optional, Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from database import Company, GBPAudit, GBPReview, CompanyWebsiteAnalysis, SessionLocal
|
from database import Company, GBPAudit, GBPReview, CompanyWebsiteAnalysis, SessionLocal, OAuthToken
|
||||||
import gemini_service
|
import gemini_service
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -1895,6 +1895,44 @@ def fetch_google_business_data(
|
|||||||
details_msg.append(f'+{sum(len(v) for v in attributes.values() if isinstance(v, dict))} atrybutów')
|
details_msg.append(f'+{sum(len(v) for v in attributes.values() if isinstance(v, dict))} atrybutów')
|
||||||
result['steps'][-1]['message'] = ', '.join(details_msg) if details_msg else 'Pobrano dane'
|
result['steps'][-1]['message'] = ', '.join(details_msg) if details_msg else 'Pobrano dane'
|
||||||
|
|
||||||
|
# OAuth: Try GBP Management API for owner-specific data
|
||||||
|
try:
|
||||||
|
from oauth_service import OAuthService
|
||||||
|
from gbp_management_service import GBPManagementService
|
||||||
|
|
||||||
|
oauth = OAuthService()
|
||||||
|
gbp_token = oauth.get_valid_token(db, company_id, 'google', 'gbp')
|
||||||
|
if gbp_token:
|
||||||
|
token_record = db.query(OAuthToken).filter(
|
||||||
|
OAuthToken.company_id == company_id,
|
||||||
|
OAuthToken.provider == 'google',
|
||||||
|
OAuthToken.service == 'gbp',
|
||||||
|
OAuthToken.is_active == True,
|
||||||
|
).first()
|
||||||
|
location_name = None
|
||||||
|
if token_record and token_record.metadata_json:
|
||||||
|
location_name = token_record.metadata_json.get('location_name')
|
||||||
|
|
||||||
|
if location_name:
|
||||||
|
gbp_mgmt = GBPManagementService(gbp_token)
|
||||||
|
reviews = gbp_mgmt.get_reviews(location_name)
|
||||||
|
if reviews:
|
||||||
|
owner_responses = sum(1 for r in reviews if r.get('reviewReply'))
|
||||||
|
result['data']['google_owner_responses_count'] = owner_responses
|
||||||
|
result['data']['google_total_reviews_with_replies'] = len(reviews)
|
||||||
|
result['data']['google_review_response_rate'] = round(
|
||||||
|
owner_responses / len(reviews) * 100, 1
|
||||||
|
) if reviews else 0
|
||||||
|
|
||||||
|
posts = gbp_mgmt.get_local_posts(location_name)
|
||||||
|
if posts:
|
||||||
|
result['data']['google_posts_count'] = len(posts)
|
||||||
|
result['data']['google_posts_data'] = posts[:10]
|
||||||
|
|
||||||
|
logger.info(f"OAuth GBP enrichment: {len(reviews or [])} reviews, {len(posts or [])} posts for company {company_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"OAuth GBP enrichment failed for company {company_id}: {e}")
|
||||||
|
|
||||||
# Step 3: Save to database
|
# Step 3: Save to database
|
||||||
result['steps'].append({
|
result['steps'].append({
|
||||||
'step': 'save_data',
|
'step': 'save_data',
|
||||||
|
|||||||
115
gbp_management_service.py
Normal file
115
gbp_management_service.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Google Business Profile Management API Client
|
||||||
|
==============================================
|
||||||
|
|
||||||
|
Uses OAuth 2.0 access tokens to access owner-specific GBP data
|
||||||
|
not available through the public Places API (e.g., owner responses to reviews, posts).
|
||||||
|
|
||||||
|
API docs: https://developers.google.com/my-business/reference/rest
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GBPManagementService:
|
||||||
|
"""Google Business Profile Management API client."""
|
||||||
|
|
||||||
|
ACCOUNT_MGMT_URL = "https://mybusinessaccountmanagement.googleapis.com/v1"
|
||||||
|
BUSINESS_INFO_URL = "https://mybusinessbusinessinformation.googleapis.com/v1"
|
||||||
|
|
||||||
|
def __init__(self, access_token: str):
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'Authorization': f'Bearer {access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
})
|
||||||
|
self.session.timeout = 15
|
||||||
|
|
||||||
|
def list_accounts(self) -> List[Dict]:
|
||||||
|
"""List all GBP accounts for the authenticated user."""
|
||||||
|
try:
|
||||||
|
resp = self.session.get(f"{self.ACCOUNT_MGMT_URL}/accounts")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get('accounts', [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GBP list_accounts failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def list_locations(self, account_name: str) -> List[Dict]:
|
||||||
|
"""List locations for a GBP account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
account_name: Full account resource name (e.g., 'accounts/123456')
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
resp = self.session.get(
|
||||||
|
f"{self.BUSINESS_INFO_URL}/{account_name}/locations",
|
||||||
|
params={'readMask': 'name,title,storefrontAddress,metadata'}
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get('locations', [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GBP list_locations failed for {account_name}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_reviews(self, location_name: str, max_results: int = 50) -> List[Dict]:
|
||||||
|
"""Get reviews INCLUDING owner responses (reviewReply).
|
||||||
|
|
||||||
|
This is the key advantage over Places API — reviewReply contains
|
||||||
|
the business owner's response to each review.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location_name: Full location resource name (e.g., 'accounts/123/locations/456')
|
||||||
|
max_results: Max reviews to return
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
reviews = []
|
||||||
|
page_token = None
|
||||||
|
while len(reviews) < max_results:
|
||||||
|
params = {'pageSize': min(50, max_results - len(reviews))}
|
||||||
|
if page_token:
|
||||||
|
params['pageToken'] = page_token
|
||||||
|
resp = self.session.get(
|
||||||
|
f"{self.ACCOUNT_MGMT_URL}/{location_name}/reviews",
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
reviews.extend(data.get('reviews', []))
|
||||||
|
page_token = data.get('nextPageToken')
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
return reviews
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GBP get_reviews failed for {location_name}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_local_posts(self, location_name: str) -> List[Dict]:
|
||||||
|
"""Get Google Posts for a location."""
|
||||||
|
try:
|
||||||
|
resp = self.session.get(
|
||||||
|
f"{self.ACCOUNT_MGMT_URL}/{location_name}/localPosts",
|
||||||
|
params={'pageSize': 20}
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get('localPosts', [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GBP get_local_posts failed for {location_name}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def match_location_by_place_id(self, account_name: str, place_id: str) -> Optional[Dict]:
|
||||||
|
"""Find a location that matches a Google Place ID.
|
||||||
|
|
||||||
|
Iterates locations and checks metadata.placeId.
|
||||||
|
"""
|
||||||
|
locations = self.list_locations(account_name)
|
||||||
|
for loc in locations:
|
||||||
|
metadata = loc.get('metadata', {})
|
||||||
|
if metadata.get('placeId') == place_id:
|
||||||
|
return loc
|
||||||
|
return None
|
||||||
@ -1238,6 +1238,56 @@ class SocialMediaAuditor:
|
|||||||
result['social_media'] = website_social
|
result['social_media'] = website_social
|
||||||
logger.info(f"Total social media profiles found: {len(website_social)} - {list(website_social.keys())}")
|
logger.info(f"Total social media profiles found: {len(website_social)} - {list(website_social.keys())}")
|
||||||
|
|
||||||
|
# OAuth: Try Facebook/Instagram Graph API for authenticated data
|
||||||
|
try:
|
||||||
|
from oauth_service import OAuthService
|
||||||
|
from facebook_graph_service import FacebookGraphService
|
||||||
|
from database import SessionLocal as OAuthSessionLocal, OAuthToken
|
||||||
|
|
||||||
|
oauth = OAuthService()
|
||||||
|
company_id = company.get('id')
|
||||||
|
if company_id:
|
||||||
|
oauth_db = OAuthSessionLocal()
|
||||||
|
try:
|
||||||
|
fb_token = oauth.get_valid_token(oauth_db, company_id, 'meta', 'facebook')
|
||||||
|
if fb_token:
|
||||||
|
fb_service = FacebookGraphService(fb_token)
|
||||||
|
token_rec = oauth_db.query(OAuthToken).filter(
|
||||||
|
OAuthToken.company_id == company_id,
|
||||||
|
OAuthToken.provider == 'meta',
|
||||||
|
OAuthToken.service == 'facebook',
|
||||||
|
OAuthToken.is_active == True,
|
||||||
|
).first()
|
||||||
|
page_id = token_rec.account_id if token_rec else None
|
||||||
|
|
||||||
|
if page_id:
|
||||||
|
page_info = fb_service.get_page_info(page_id)
|
||||||
|
if page_info:
|
||||||
|
result['oauth_facebook'] = {
|
||||||
|
'fan_count': page_info.get('fan_count'),
|
||||||
|
'category': page_info.get('category'),
|
||||||
|
'data_source': 'oauth_api',
|
||||||
|
}
|
||||||
|
insights = fb_service.get_page_insights(page_id)
|
||||||
|
if insights:
|
||||||
|
result['oauth_facebook_insights'] = insights
|
||||||
|
|
||||||
|
ig_id = fb_service.get_instagram_account(page_id)
|
||||||
|
if ig_id:
|
||||||
|
ig_insights = fb_service.get_ig_media_insights(ig_id)
|
||||||
|
if ig_insights:
|
||||||
|
result['oauth_instagram'] = {
|
||||||
|
**ig_insights,
|
||||||
|
'data_source': 'oauth_api',
|
||||||
|
}
|
||||||
|
logger.info(f"OAuth Facebook/IG enrichment done for company {company_id}")
|
||||||
|
finally:
|
||||||
|
oauth_db.close()
|
||||||
|
except ImportError:
|
||||||
|
pass # Services not yet available
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"OAuth social media enrichment failed: {e}")
|
||||||
|
|
||||||
# 5. Enrich social media profiles with additional data
|
# 5. Enrich social media profiles with additional data
|
||||||
enriched_profiles = {}
|
enriched_profiles = {}
|
||||||
for platform, url in website_social.items():
|
for platform, url in website_social.items():
|
||||||
|
|||||||
149
search_console_service.py
Normal file
149
search_console_service.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Google Search Console API Client
|
||||||
|
=================================
|
||||||
|
|
||||||
|
Uses OAuth 2.0 to fetch search analytics data (clicks, impressions, CTR, positions).
|
||||||
|
|
||||||
|
API docs: https://developers.google.com/webmaster-tools/v3/searchanalytics
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchConsoleService:
|
||||||
|
"""Google Search Console API client."""
|
||||||
|
|
||||||
|
BASE_URL = "https://www.googleapis.com/webmasters/v3"
|
||||||
|
|
||||||
|
def __init__(self, access_token: str):
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'Authorization': f'Bearer {access_token}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
})
|
||||||
|
self.session.timeout = 15
|
||||||
|
|
||||||
|
def list_sites(self) -> List[Dict]:
|
||||||
|
"""List verified sites in Search Console."""
|
||||||
|
try:
|
||||||
|
resp = self.session.get(f"{self.BASE_URL}/sites")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get('siteEntry', [])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Search Console list_sites failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _normalize_site_url(self, url: str) -> Optional[str]:
|
||||||
|
"""Try to find matching site URL in Search Console.
|
||||||
|
|
||||||
|
Search Console uses exact URL format — with/without trailing slash,
|
||||||
|
http/https, www/no-www. Try common variants.
|
||||||
|
"""
|
||||||
|
sites = self.list_sites()
|
||||||
|
site_urls = [s.get('siteUrl', '') for s in sites]
|
||||||
|
|
||||||
|
# Direct match
|
||||||
|
if url in site_urls:
|
||||||
|
return url
|
||||||
|
|
||||||
|
# Try variants
|
||||||
|
variants = [url]
|
||||||
|
if not url.endswith('/'):
|
||||||
|
variants.append(url + '/')
|
||||||
|
if url.startswith('https://'):
|
||||||
|
variants.append(url.replace('https://', 'http://'))
|
||||||
|
if url.startswith('http://'):
|
||||||
|
variants.append(url.replace('http://', 'https://'))
|
||||||
|
# www variants
|
||||||
|
for v in list(variants):
|
||||||
|
if '://www.' in v:
|
||||||
|
variants.append(v.replace('://www.', '://'))
|
||||||
|
else:
|
||||||
|
variants.append(v.replace('://', '://www.'))
|
||||||
|
|
||||||
|
for v in variants:
|
||||||
|
if v in site_urls:
|
||||||
|
return v
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_search_analytics(self, site_url: str, days: int = 28) -> Dict:
|
||||||
|
"""Get search analytics for a site.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: clicks, impressions, ctr, position,
|
||||||
|
top_queries (list), top_pages (list), period_days
|
||||||
|
"""
|
||||||
|
normalized = self._normalize_site_url(site_url)
|
||||||
|
if not normalized:
|
||||||
|
logger.warning(f"Site {site_url} not found in Search Console")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
end_date = datetime.now() - timedelta(days=3) # SC data has ~3 day delay
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Totals
|
||||||
|
resp = self.session.post(
|
||||||
|
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
|
||||||
|
json={
|
||||||
|
'startDate': start_date.strftime('%Y-%m-%d'),
|
||||||
|
'endDate': end_date.strftime('%Y-%m-%d'),
|
||||||
|
'dimensions': [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
rows = resp.json().get('rows', [])
|
||||||
|
|
||||||
|
totals = rows[0] if rows else {}
|
||||||
|
result = {
|
||||||
|
'clicks': totals.get('clicks', 0),
|
||||||
|
'impressions': totals.get('impressions', 0),
|
||||||
|
'ctr': round(totals.get('ctr', 0) * 100, 2),
|
||||||
|
'position': round(totals.get('position', 0), 1),
|
||||||
|
'period_days': days,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Top queries
|
||||||
|
resp_q = self.session.post(
|
||||||
|
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
|
||||||
|
json={
|
||||||
|
'startDate': start_date.strftime('%Y-%m-%d'),
|
||||||
|
'endDate': end_date.strftime('%Y-%m-%d'),
|
||||||
|
'dimensions': ['query'],
|
||||||
|
'rowLimit': 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if resp_q.status_code == 200:
|
||||||
|
result['top_queries'] = [
|
||||||
|
{'query': r['keys'][0], 'clicks': r.get('clicks', 0), 'impressions': r.get('impressions', 0)}
|
||||||
|
for r in resp_q.json().get('rows', [])
|
||||||
|
]
|
||||||
|
|
||||||
|
# Top pages
|
||||||
|
resp_p = self.session.post(
|
||||||
|
f"{self.BASE_URL}/sites/{requests.utils.quote(normalized, safe='')}/searchAnalytics/query",
|
||||||
|
json={
|
||||||
|
'startDate': start_date.strftime('%Y-%m-%d'),
|
||||||
|
'endDate': end_date.strftime('%Y-%m-%d'),
|
||||||
|
'dimensions': ['page'],
|
||||||
|
'rowLimit': 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if resp_p.status_code == 200:
|
||||||
|
result['top_pages'] = [
|
||||||
|
{'page': r['keys'][0], 'clicks': r.get('clicks', 0), 'impressions': r.get('impressions', 0)}
|
||||||
|
for r in resp_p.json().get('rows', [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Search Console analytics failed for {site_url}: {e}")
|
||||||
|
return {}
|
||||||
617
templates/admin/company_settings.html
Normal file
617
templates/admin/company_settings.html
Normal file
@ -0,0 +1,617 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Ustawienia firmy - {{ company.name }} - Norda Biznes Partner{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
.settings-header {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header h1 {
|
||||||
|
font-size: var(--font-size-3xl);
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header .breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header .breadcrumb a {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header .breadcrumb a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header .breadcrumb svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-subtitle {
|
||||||
|
margin: var(--spacing-xs) 0 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* OAuth Cards Grid */
|
||||||
|
.oauth-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
padding: var(--spacing-lg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card-icon.google {
|
||||||
|
background: #f0f7ff;
|
||||||
|
color: #4285F4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card-icon.meta {
|
||||||
|
background: #f0f0ff;
|
||||||
|
color: #1877F2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card-icon svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card-title {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card-desc {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 var(--spacing-md) 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.oauth-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-xs) var(--spacing-sm);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-status.connected {
|
||||||
|
background: var(--success-light, #dcfce7);
|
||||||
|
color: var(--success, #16a34a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-status.unavailable {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-status.expired {
|
||||||
|
background: var(--warning-light, #fef3c7);
|
||||||
|
color: var(--warning, #d97706);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-status svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-account-name {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oauth-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oauth {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oauth.connect {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oauth.connect:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oauth.disconnect {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--error, #dc2626);
|
||||||
|
border: 1px solid var(--error, #dc2626);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oauth.disconnect:hover {
|
||||||
|
background: var(--error, #dc2626);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oauth.discover {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oauth.discover:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oauth:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-oauth svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast notification */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--spacing-lg);
|
||||||
|
right: var(--spacing-lg);
|
||||||
|
padding: var(--spacing-md) var(--spacing-lg);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: toastIn 0.3s ease-out;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
background: var(--success, #16a34a);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
background: var(--error, #dc2626);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastIn {
|
||||||
|
from { opacity: 0; transform: translateY(-20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section title */
|
||||||
|
.section-title {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0 0 var(--spacing-md) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 var(--spacing-lg) 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Back link */
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
margin-bottom: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container">
|
||||||
|
<!-- Breadcrumb -->
|
||||||
|
<div class="settings-header">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="{{ url_for('admin.admin_companies') }}">Firmy</a>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
<a href="{{ url_for('admin.admin_company_get', company_id=company.id) }}">{{ company.name }}</a>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
<span>Ustawienia</span>
|
||||||
|
</div>
|
||||||
|
<h1>Ustawienia firmy</h1>
|
||||||
|
<p class="settings-subtitle">{{ company.name }} — integracje z zewnętrznymi serwisami</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OAuth Integrations Section -->
|
||||||
|
<h2 class="section-title">Połączenia OAuth</h2>
|
||||||
|
<p class="section-desc">Połącz konta zewnętrznych serwisów, aby wzbogacić audyty o dodatkowe dane. Każdy serwis wymaga osobnej autoryzacji.</p>
|
||||||
|
|
||||||
|
<div class="oauth-cards">
|
||||||
|
<!-- Google Business Profile -->
|
||||||
|
{% set gbp_conn = connections.get('google/gbp', {}) %}
|
||||||
|
<div class="oauth-card" data-provider="google" data-service="gbp">
|
||||||
|
<div class="oauth-card-header">
|
||||||
|
<div class="oauth-card-icon google">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="oauth-card-title">Google Business Profile</h3>
|
||||||
|
</div>
|
||||||
|
<p class="oauth-card-desc">Opinie z odpowiedziami właściciela, posty, zdjęcia, Q&A i statystyki widoczności wizytówki Google.</p>
|
||||||
|
|
||||||
|
{% if gbp_conn.get('connected') %}
|
||||||
|
{% if gbp_conn.get('is_expired') %}
|
||||||
|
<div class="oauth-status expired">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
Token wygasł — wymagane ponowne połączenie
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="oauth-status connected">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
Połączono
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if gbp_conn.get('account_name') %}
|
||||||
|
<p class="oauth-account-name">Konto: {{ gbp_conn.account_name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="oauth-card-actions">
|
||||||
|
<button class="btn-oauth discover" onclick="discoverGBPLocations()">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
|
Wykryj lokalizacje
|
||||||
|
</button>
|
||||||
|
<button class="btn-oauth disconnect" onclick="disconnectOAuth('google', 'gbp')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
|
||||||
|
Rozłącz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% elif oauth_available.get('google') %}
|
||||||
|
<div class="oauth-card-actions">
|
||||||
|
<button class="btn-oauth connect" onclick="connectOAuth('google', 'gbp')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||||
|
Połącz konto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="oauth-status unavailable">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||||
|
Niedostępne — wymaga konfiguracji administratora
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Google Search Console -->
|
||||||
|
{% set sc_conn = connections.get('google/search_console', {}) %}
|
||||||
|
<div class="oauth-card" data-provider="google" data-service="search_console">
|
||||||
|
<div class="oauth-card-header">
|
||||||
|
<div class="oauth-card-icon google">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="oauth-card-title">Google Search Console</h3>
|
||||||
|
</div>
|
||||||
|
<p class="oauth-card-desc">Zapytania wyszukiwania, CTR, średnia pozycja, indeksowanie stron i dane o wydajności w Google.</p>
|
||||||
|
|
||||||
|
{% if sc_conn.get('connected') %}
|
||||||
|
{% if sc_conn.get('is_expired') %}
|
||||||
|
<div class="oauth-status expired">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
Token wygasł — wymagane ponowne połączenie
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="oauth-status connected">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
Połączono
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if sc_conn.get('account_name') %}
|
||||||
|
<p class="oauth-account-name">Konto: {{ sc_conn.account_name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="oauth-card-actions">
|
||||||
|
<button class="btn-oauth disconnect" onclick="disconnectOAuth('google', 'search_console')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
|
||||||
|
Rozłącz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% elif oauth_available.get('google') %}
|
||||||
|
<div class="oauth-card-actions">
|
||||||
|
<button class="btn-oauth connect" onclick="connectOAuth('google', 'search_console')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||||
|
Połącz konto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="oauth-status unavailable">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||||
|
Niedostępne — wymaga konfiguracji administratora
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Facebook -->
|
||||||
|
{% set fb_conn = connections.get('meta/facebook', {}) %}
|
||||||
|
<div class="oauth-card" data-provider="meta" data-service="facebook">
|
||||||
|
<div class="oauth-card-header">
|
||||||
|
<div class="oauth-card-icon meta">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="oauth-card-title">Facebook</h3>
|
||||||
|
</div>
|
||||||
|
<p class="oauth-card-desc">Zasięg strony, impressions, engagement, dane demograficzne odbiorców i statystyki postów.</p>
|
||||||
|
|
||||||
|
{% if fb_conn.get('connected') %}
|
||||||
|
{% if fb_conn.get('is_expired') %}
|
||||||
|
<div class="oauth-status expired">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
Token wygasł — wymagane ponowne połączenie
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="oauth-status connected">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
Połączono
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if fb_conn.get('account_name') %}
|
||||||
|
<p class="oauth-account-name">Strona: {{ fb_conn.account_name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="oauth-card-actions">
|
||||||
|
<button class="btn-oauth disconnect" onclick="disconnectOAuth('meta', 'facebook')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
|
||||||
|
Rozłącz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% elif oauth_available.get('meta') %}
|
||||||
|
<div class="oauth-card-actions">
|
||||||
|
<button class="btn-oauth connect" onclick="connectOAuth('meta', 'facebook')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||||
|
Połącz konto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="oauth-status unavailable">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||||
|
Niedostępne — wymaga konfiguracji administratora
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instagram -->
|
||||||
|
{% set ig_conn = connections.get('meta/instagram', {}) %}
|
||||||
|
<div class="oauth-card" data-provider="meta" data-service="instagram">
|
||||||
|
<div class="oauth-card-header">
|
||||||
|
<div class="oauth-card-icon meta">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z"/></svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="oauth-card-title">Instagram</h3>
|
||||||
|
</div>
|
||||||
|
<p class="oauth-card-desc">Stories, reels, engagement, zasięg postów i dane demograficzne obserwujących konto firmowe.</p>
|
||||||
|
|
||||||
|
{% if ig_conn.get('connected') %}
|
||||||
|
{% if ig_conn.get('is_expired') %}
|
||||||
|
<div class="oauth-status expired">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
Token wygasł — wymagane ponowne połączenie
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="oauth-status connected">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||||
|
Połączono
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if ig_conn.get('account_name') %}
|
||||||
|
<p class="oauth-account-name">Konto: {{ ig_conn.account_name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="oauth-card-actions">
|
||||||
|
<button class="btn-oauth disconnect" onclick="disconnectOAuth('meta', 'instagram')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>
|
||||||
|
Rozłącz
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% elif oauth_available.get('meta') %}
|
||||||
|
<div class="oauth-card-actions">
|
||||||
|
<button class="btn-oauth connect" onclick="connectOAuth('meta', 'instagram')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
|
||||||
|
Połącz konto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="oauth-status unavailable">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
|
||||||
|
Niedostępne — wymaga konfiguracji administratora
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="{{ url_for('admin.admin_company_get', company_id=company.id) }}" class="back-link">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||||
|
Powrót do szczegółów firmy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
// OAuth Settings JS
|
||||||
|
var csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||||
|
|
||||||
|
function showToast(message, type) {
|
||||||
|
var existing = document.querySelector('.toast');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
var toast = document.createElement('div');
|
||||||
|
toast.className = 'toast ' + (type || 'success');
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transition = 'opacity 0.3s';
|
||||||
|
setTimeout(function() { toast.remove(); }, 300);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectOAuth(provider, service) {
|
||||||
|
fetch('/api/oauth/connect/' + provider + '/' + service, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.auth_url) {
|
||||||
|
window.location.href = data.auth_url;
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Nie udało się rozpocząć autoryzacji', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
showToast('Błąd połączenia: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectOAuth(provider, service) {
|
||||||
|
if (!confirm('Czy na pewno chcesz rozłączyć ten serwis? Audyty stracą dostęp do rozszerzonych danych.')) return;
|
||||||
|
|
||||||
|
fetch('/api/oauth/disconnect/' + provider + '/' + service, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
showToast('Serwis rozłączony pomyślnie');
|
||||||
|
setTimeout(function() { location.reload(); }, 1000);
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Nie udało się rozłączyć', 'error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
showToast('Błąd: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverGBPLocations() {
|
||||||
|
var btn = document.querySelector('.btn-oauth.discover');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<svg class="spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px;animation:spin 1s linear infinite"><circle cx="12" cy="12" r="10" opacity="0.3"/><path d="M12 2a10 10 0 0 1 10 10"/></svg> Szukam...';
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/oauth/google/discover-locations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrfToken,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.success) {
|
||||||
|
var count = data.locations ? data.locations.length : 0;
|
||||||
|
showToast('Znaleziono ' + count + ' lokalizacji GBP');
|
||||||
|
} else {
|
||||||
|
showToast(data.error || 'Nie udało się wyszukać lokalizacji', 'error');
|
||||||
|
}
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Wykryj lokalizacje';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
showToast('Błąd: ' + err.message, 'error');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Wykryj lokalizacje';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle URL params for OAuth callback results
|
||||||
|
(function() {
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('oauth_success')) {
|
||||||
|
showToast('Pomyślnie połączono: ' + params.get('oauth_success'));
|
||||||
|
// Clean URL
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
}
|
||||||
|
if (params.get('oauth_error')) {
|
||||||
|
var errorMap = {
|
||||||
|
'missing_params': 'Brak wymaganych parametrów',
|
||||||
|
'invalid_state': 'Nieprawidłowy token sesji',
|
||||||
|
'invalid_state_format': 'Nieprawidłowy format tokenu',
|
||||||
|
'unauthorized': 'Brak uprawnień',
|
||||||
|
'token_exchange_failed': 'Wymiana tokenu nie powiodła się',
|
||||||
|
'save_failed': 'Nie udało się zapisać tokenu'
|
||||||
|
};
|
||||||
|
var error = params.get('oauth_error');
|
||||||
|
showToast(errorMap[error] || 'Błąd OAuth: ' + error, 'error');
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
{% endblock %}
|
||||||
Loading…
Reference in New Issue
Block a user