diff --git a/src/platform/.env.example b/src/platform/.env.example index 4c17dad757..f24b4e5468 100644 --- a/src/platform/.env.example +++ b/src/platform/.env.example @@ -42,5 +42,7 @@ NEXT_PUBLIC_PAYMENTS_STANDARD_PRICE_ID= PAYMENTS_STANDARD_PRICE_ID= NEXT_PUBLIC_PAYMENTS_PREMIUM_PRICE_ID= PAYMENTS_PREMIUM_PRICE_ID= +NEXT_PUBLIC_PAYMENT_CLIENT_TOKEN= +NEXT_PUBLIC_PAYMENT_ENVIRONMENT= diff --git a/src/platform/Dockerfile b/src/platform/Dockerfile index 0824e594d2..0e81611998 100644 --- a/src/platform/Dockerfile +++ b/src/platform/Dockerfile @@ -36,6 +36,8 @@ COPY --from=builder /app/package.json ./package.json # Automatically leverage output traces to reduce image size # https://nextjs.org/docs/advanced-features/output-file-tracing COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs diff --git a/src/platform/package.json b/src/platform/package.json index 9671b61c0f..7993750445 100644 --- a/src/platform/package.json +++ b/src/platform/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev", "build": "next build", - "postbuild": "node scripts/copy-standalone-assets.mjs", + "postbuild": "node -e \"const fs=require('node:fs'); const path=require('node:path'); const projectRoot=process.cwd(); const standaloneRoot=path.join(projectRoot,'.next','standalone'); const publicSource=path.join(projectRoot,'public'); const publicTarget=path.join(standaloneRoot,'public'); const staticSource=path.join(projectRoot,'.next','static'); const staticTarget=path.join(standaloneRoot,'.next','static'); const copyDirectory=(source,target)=>{ if(!fs.existsSync(source)) return false; fs.mkdirSync(path.dirname(target),{recursive:true}); fs.rmSync(target,{recursive:true,force:true}); fs.cpSync(source,target,{recursive:true}); return true; }; if(!fs.existsSync(standaloneRoot)){ throw new Error(`Standalone output not found at ${standaloneRoot}. Run 'yarn build' first.`); } copyDirectory(publicSource, publicTarget); copyDirectory(staticSource, staticTarget);\"", "start": "node -e \"const { loadEnvConfig } = require('@next/env'); loadEnvConfig(process.cwd()); process.env.HOSTNAME='localhost'; require('./.next/standalone/server.js')\"", "lint": "next lint", "format": "prettier --write .", diff --git a/src/platform/scripts/copy-standalone-assets.mjs b/src/platform/scripts/copy-standalone-assets.mjs deleted file mode 100644 index 515b5e173a..0000000000 --- a/src/platform/scripts/copy-standalone-assets.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -const projectRoot = process.cwd(); -const standaloneRoot = path.join(projectRoot, '.next', 'standalone'); -const publicSource = path.join(projectRoot, 'public'); -const publicTarget = path.join(standaloneRoot, 'public'); -const staticSource = path.join(projectRoot, '.next', 'static'); -const staticTarget = path.join(standaloneRoot, '.next', 'static'); - -const copyDirectory = (source, target) => { - if (!fs.existsSync(source)) { - return false; - } - - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.rmSync(target, { recursive: true, force: true }); - fs.cpSync(source, target, { recursive: true }); - return true; -}; - -if (!fs.existsSync(standaloneRoot)) { - throw new Error( - `Standalone output not found at ${standaloneRoot}. Run \"yarn build\" first.` - ); -} - -copyDirectory(publicSource, publicTarget); -copyDirectory(staticSource, staticTarget); diff --git a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx index d7cee9cc73..9e14a176f8 100644 --- a/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx +++ b/src/platform/src/app/(dashboard)/(individual)/user/(auth)/login/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, type FormEvent } from 'react'; import AuthLayout from '@/shared/layouts/AuthLayout'; import SocialAuthSection from '@/shared/components/auth/SocialAuthSection'; +import SelectedEmailCard from '@/shared/components/auth/SelectedEmailCard'; import { signIn } from 'next-auth/react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; @@ -201,27 +202,7 @@ export default function LoginPage() { ) : (
-
-
-
-

- Account -

-

- {emailValue || 'your email address'} -

-
- - -
-
+ ) : ( -
-
-
-

- Account -

-

- {emailValue || 'your email address'} -

-
- - -
-
+ + diff --git a/src/platform/src/modules/analytics/components/AnalyticsCard.tsx b/src/platform/src/modules/analytics/components/AnalyticsCard.tsx index 82b6f53218..b0b4196217 100644 --- a/src/platform/src/modules/analytics/components/AnalyticsCard.tsx +++ b/src/platform/src/modules/analytics/components/AnalyticsCard.tsx @@ -18,6 +18,7 @@ import type { PollutantType } from '@/shared/components/charts/types'; import { useResizeObserver } from '@/shared/hooks'; import { AqWind01 } from '@airqo/icons-react'; import { anonymizeSiteData, trackEvent } from '@/shared/utils/analytics'; +import { getSiteDisplayName } from '@/shared/utils/siteUtils'; export const AnalyticsCard: React.FC = memo( ({ siteData, className, showIcon = true, selectedPollutant, onClick }) => { @@ -45,7 +46,6 @@ export const AnalyticsCard: React.FC = memo( useResizeObserver(locationRef.current, checkTruncation); const { - name, location, value, status: rawStatus, @@ -69,8 +69,7 @@ export const AnalyticsCard: React.FC = memo( // Use selected pollutant for display, fallback to site data pollutant const displayPollutant = selectedPollutant || (pollutant as PollutantType); - // Format name for display by replacing underscores with spaces - const displayName = name.replace(/_/g, ' '); + const displayName = getSiteDisplayName(siteData); // Get appropriate icons // Narrow types before indexing into the icon maps to satisfy TS @@ -142,8 +141,8 @@ export const AnalyticsCard: React.FC = memo( 0 ? 'worsened' : 'improved'} by ${Math.abs(percentageDifference).toFixed(1)}% compared to last week.` - : 'Air quality trend compared to last week.' + ? `Air quality ${percentageDifference > 0 ? 'worsened' : 'improved'} by ${Math.abs(percentageDifference).toFixed(1)}% compared with the previous reading.` + : 'Air quality trend compared with the previous reading.' } className="bg-black" > diff --git a/src/platform/src/modules/analytics/components/AnalyticsDashboard.tsx b/src/platform/src/modules/analytics/components/AnalyticsDashboard.tsx index ab030f58d3..d3c7aa6028 100644 --- a/src/platform/src/modules/analytics/components/AnalyticsDashboard.tsx +++ b/src/platform/src/modules/analytics/components/AnalyticsDashboard.tsx @@ -23,6 +23,7 @@ import type { SiteData } from '../types'; import { openMoreInsights } from '@/shared/store/insightsSlice'; import type { NormalizedChartData } from '@/shared/components/charts/types'; import { trackEvent } from '@/shared/utils/analytics'; +import { getSiteDisplayName } from '@/shared/utils/siteUtils'; import { useActiveGroupCohorts, useCohort, @@ -112,6 +113,7 @@ export const AnalyticsDashboard: React.FC = ({ siteCards, isLoading: siteCardsLoading, isRefreshing: siteCardsRefreshing, + error: siteCardsError, refetch: refreshSiteCards, } = useAnalyticsSiteCards({ selectedSiteIds, @@ -194,15 +196,11 @@ export const AnalyticsDashboard: React.FC = ({ return Array.from(uniqueSiteIds) .map(siteId => { const siteData = selectedSites.find(site => site._id === siteId); + const displayName = getSiteDisplayName(siteData || {}); return { _id: siteId, - name: - siteData?.search_name || - siteData?.name || - siteData?.formatted_name || - siteData?.generated_name || - 'Unknown Site', - search_name: siteData?.search_name, + name: displayName, + search_name: displayName, country: siteData?.country, }; }) @@ -276,13 +274,8 @@ export const AnalyticsDashboard: React.FC = ({ sites || selectedSites.map(site => ({ _id: site._id, - name: - site.search_name || - site.name || - site.formatted_name || - site.generated_name || - 'Unknown Site', - search_name: site.search_name || site.name, + name: getSiteDisplayName(site), + search_name: getSiteDisplayName(site), country: site.country, })); @@ -291,10 +284,11 @@ export const AnalyticsDashboard: React.FC = ({ // Handle individual card click - open more insights for specific site const handleCardClick = (siteData: SiteData) => { + const displayName = getSiteDisplayName(siteData); const selectedSite = { _id: siteData._id, - name: siteData.search_name || siteData.name, - search_name: siteData.search_name || siteData.name, + name: displayName, + search_name: displayName, country: siteData.location, }; @@ -457,10 +451,12 @@ export const AnalyticsDashboard: React.FC = ({ sites={siteCards} onManageFavorites={handleManageFavorites} onRefresh={handleRefreshDashboard} - isRefreshing={isRefreshing} + isRefreshing={siteCardsRefreshing || isRefreshing} selectedPollutant={filters.pollutant} onCardClick={handleCardClick} - isLoading={siteCardsLoading || siteCardsRefreshing || isRefreshing} + isLoading={siteCardsLoading} + placeholderCount={selectedSites.length} + errorMessage={siteCardsError} showIcon={showIcons} onShowIconsChange={setShowIcons} infoLine={ diff --git a/src/platform/src/modules/analytics/components/DownloadDialog.tsx b/src/platform/src/modules/analytics/components/DownloadDialog.tsx index f3bb075f16..8ed2726641 100644 --- a/src/platform/src/modules/analytics/components/DownloadDialog.tsx +++ b/src/platform/src/modules/analytics/components/DownloadDialog.tsx @@ -12,6 +12,7 @@ import { FREQUENCY_LABELS, POLLUTANT_LABELS, } from '@/shared/components/charts/constants'; +import { getSiteDisplayName } from '@/shared/utils/siteUtils'; interface DownloadDialogProps { isOpen: boolean; @@ -180,12 +181,7 @@ export const DownloadDialog: React.FC = ({ () => selectedSites.map(site => ({ id: site._id, - displayName: - site.search_name || - site.name || - site.formatted_name || - site.generated_name || - `Site ${site._id.slice(-6)}`, + displayName: getSiteDisplayName(site), location: site.country || site.region || site.city || '', })), [selectedSites] diff --git a/src/platform/src/modules/analytics/components/QuickAccessCard.tsx b/src/platform/src/modules/analytics/components/QuickAccessCard.tsx index 3ad226eaa9..fdb5bf7034 100644 --- a/src/platform/src/modules/analytics/components/QuickAccessCard.tsx +++ b/src/platform/src/modules/analytics/components/QuickAccessCard.tsx @@ -7,6 +7,100 @@ import { cn } from '@/shared/lib/utils'; import type { QuickAccessLocationsProps } from '../types'; import { AnalyticsCard } from './AnalyticsCard'; import { LoadingSpinner } from '@/shared/components/ui/loading-spinner'; +import { EmptyState } from '@/shared/components/ui'; +import { getUserFriendlyErrorMessage } from '@/shared/utils/errorMessages'; + +const QUICK_ACCESS_ERROR_MESSAGE = + 'Your favorites are saved. Refresh to try loading the latest readings again.'; + +const QUICK_ACCESS_ERROR_TECHNICAL_PATTERNS = [ + /failed to fetch/i, + /request failed/i, + /internal server error/i, + /service unavailable/i, + /gateway timeout/i, + /axioserror/i, + /err_network/i, + /err_canceled/i, + /timeout/i, + /abort/i, + /exception/i, + /traceback/i, + /stack trace/i, + /token/i, + /session/i, + /unauthorized/i, + /forbidden/i, + /database/i, + /sql/i, +]; + +const isCancellationMessage = (message: string): boolean => { + const lowerMessage = message.toLowerCase(); + + return ( + lowerMessage.includes('aborterror') || + lowerMessage.includes('canceled') || + lowerMessage.includes('cancelled') || + lowerMessage.includes('request aborted') || + lowerMessage.includes('request canceled') || + lowerMessage.includes('request cancelled') + ); +}; + +const redactSensitiveTokens = (message: string): string => { + return message + .replace(/Bearer\s+[A-Za-z0-9-._~+/]+=*/gi, '[redacted]') + .replace( + /\beyJ[A-Za-z0-9-._~+/]+=*\.[A-Za-z0-9-._~+/]+=*(?:\.[A-Za-z0-9-._~+/]+=*)?\b/g, + '[redacted]' + ) + .replace( + /(token|session|accessToken|refreshToken)=([^&\s]+)/gi, + '$1=[redacted]' + ) + .replace(/[\w.-]+@[\w.-]+\.\w+/g, '[redacted]') + .replace( + /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, + '[redacted]' + ) + .replace(/[A-Za-z]:\\[\\\w\s.-]+/g, '[redacted]') + .replace(/\s+/g, ' ') + .trim(); +}; + +const getDisplayErrorMessage = ( + errorMessage: string | null | undefined +): string | null => { + if (!errorMessage?.trim()) { + return null; + } + + const trimmedMessage = errorMessage.trim(); + if (isCancellationMessage(trimmedMessage)) { + return null; + } + + const friendlyMessage = getUserFriendlyErrorMessage(trimmedMessage).trim(); + if (friendlyMessage !== trimmedMessage) { + const sanitizedFriendlyMessage = redactSensitiveTokens(friendlyMessage); + return sanitizedFriendlyMessage || QUICK_ACCESS_ERROR_MESSAGE; + } + + const sanitizedMessage = redactSensitiveTokens(trimmedMessage); + if ( + !sanitizedMessage || + QUICK_ACCESS_ERROR_TECHNICAL_PATTERNS.some(pattern => + pattern.test(sanitizedMessage) + ) + ) { + return QUICK_ACCESS_ERROR_MESSAGE; + } + + return sanitizedMessage.length > 140 + ? `${sanitizedMessage.slice(0, 137)}...` + : sanitizedMessage; +}; export const QuickAccessCard: React.FC = memo( ({ @@ -23,8 +117,20 @@ export const QuickAccessCard: React.FC = memo( onShowIconsChange, selectedPollutant, isLoading = false, + placeholderCount = 0, + errorMessage = null, onCardClick, }) => { + const visiblePlaceholderCount = Math.min(Math.max(placeholderCount, 1), 4); + const hasUsableSites = sites.length > 0; + const displayErrorMessage = getDisplayErrorMessage(errorMessage); + const shouldShowSkeleton = + visiblePlaceholderCount > 0 && isLoading && !hasUsableSites; + const shouldShowErrorState = + !isLoading && !hasUsableSites && Boolean(displayErrorMessage); + const shouldShowAddFavorite = + !shouldShowSkeleton && !shouldShowErrorState && sites.length < 4; + return (
= memo( onShowIconsChange={onShowIconsChange} /> + {isRefreshing && sites.length > 0 && !isLoading && ( +
+
+ + Refreshing latest readings... +
+
+ )} + {warningBanner &&
{warningBanner}
} {/* Sites Grid */} -
-
- {/* Render site cards up to 4 to leave room for Add Location */} - {sites.slice(0, 4).map(site => ( - {})} +
+ {shouldShowErrorState ? ( +
+ - ))} - - {/* Show Add Location button if less than 4 sites */} - {sites.length < 4 && ( -
+ ) : shouldShowSkeleton ? ( + Array.from({ length: visiblePlaceholderCount }).map((_, index) => ( +