diff --git a/.gitignore b/.gitignore index 537d5a0..2cd3236 100644 --- a/.gitignore +++ b/.gitignore @@ -43,8 +43,7 @@ $RECYCLE.BIN/ __MACOSX/ .AppleDouble .LSOverride -Icon[ -] +Icon? ._* .DocumentRevisions-V100 .fseventsd diff --git a/frontend/src/hooks/useAnalysisPdf.ts b/frontend/src/hooks/useAnalysisPdf.ts index fe9663b..aa17924 100644 --- a/frontend/src/hooks/useAnalysisPdf.ts +++ b/frontend/src/hooks/useAnalysisPdf.ts @@ -19,6 +19,7 @@ import { requestPdfExportToken } from '@/api/projects' import { notify } from '@/components/ui/toastConfig' import { useTheme } from '@/hooks/useTheme' import { useLocale } from '@/hooks/useLocale' +import { useTranslation } from 'react-i18next' /** Strips characters that would break a download filename across OSes. */ function sanitizeFileName(value: string) { @@ -52,6 +53,7 @@ function triggerDownload(blob: Blob, filename: string) { * runs the full export flow described in the file header. */ export function useAnalysisPdf() { + const { t } = useTranslation('analysis') const [isExporting, setIsExporting] = useState(false) const { resolved: resolvedTheme } = useTheme() const { locale } = useLocale() @@ -59,7 +61,7 @@ export function useAnalysisPdf() { async function handleExportPdf(projectId: string, projectName: string, beforeExport?: () => Promise) { const exportUrl = import.meta.env.VITE_PDF_EXPORT_URL if (!exportUrl) { - notify.error('PDF export service is not configured') + notify.error(t('toasts.pdfServiceNotConfigured')) return } @@ -98,7 +100,7 @@ export function useAnalysisPdf() { const blob = await response.blob() triggerDownload(blob, filename) - notify.success(`PDF exported: ${filename}`) + notify.success(t('toasts.pdfExported', { filename })) } catch (error) { notify.error(error instanceof Error ? error.message : 'Failed to export the PDF report') } finally { diff --git a/frontend/src/hooks/useCanvasInteractions.ts b/frontend/src/hooks/useCanvasInteractions.ts index 9a275e2..ce39f42 100644 --- a/frontend/src/hooks/useCanvasInteractions.ts +++ b/frontend/src/hooks/useCanvasInteractions.ts @@ -54,15 +54,17 @@ import type { WorkbenchPanelState } from '@/hooks/usePanelState' import { computeSegmentHulls } from '@/lib/segmentVisualization' import type { SolarPanel, RoofSegment } from '@/lib/buildingInsights' import { PANEL_MODELS, getPanelModel, type PanelModel } from '@shared/types' +import { useTranslation } from 'react-i18next' +import type { TFunction } from 'i18next' -function getPlacementErrorMessage(reason: 'bounds' | 'mask' | 'overlap') { +function getPlacementErrorMessage(t: TFunction, reason: 'bounds' | 'mask' | 'overlap') { switch (reason) { case 'mask': - return 'That placement leaves the detected roof boundary.' + return t('toasts.placementMask') case 'overlap': - return 'That placement overlaps another panel.' + return t('toasts.placementOverlap') default: - return 'That placement leaves the roof image bounds.' + return t('toasts.placementBounds') } } @@ -122,6 +124,7 @@ export function useCanvasInteractions({ solarPanels, roofSegments }: UseCanvasInteractionsOptions) { + const { t } = useTranslation('workbench') const rotationTimeoutRef = useRef | null>(null) const groupRotateStateRef = useRef<{ snapshots: Map @@ -283,7 +286,7 @@ export function useCanvasInteractions({ if (!locationId) return setPendingPanelId(panelId) - notify.info('Recomputing panel yield from cached monthly flux data...') + notify.info(t('toasts.recomputingPanelYield')) try { const result = await recomputeFlux(locationId, { @@ -491,7 +494,7 @@ export function useCanvasInteractions({ // Bounds and mask failures still revert because they are real boundary violations if (placementError && (!enteredViaOverlap || (placementError !== 'overlap' && placementError !== 'bounds'))) { resetPosition() - notify.error(getPlacementErrorMessage(placementError)) + notify.error(getPlacementErrorMessage(t, placementError)) return } @@ -604,7 +607,7 @@ export function useCanvasInteractions({ })) ) resetPosition() - notify.error(`Group move failed: ${getPlacementErrorMessage(placementError)}`) + notify.error(t('toasts.groupMoveFailed', { reason: getPlacementErrorMessage(t, placementError) })) return } moves.push({ id: sp.id, prevCenter, nextCenter }) @@ -615,7 +618,7 @@ export function useCanvasInteractions({ if (!locationId) return setPendingPanelId(panelId) - notify.info(`Recomputing yield for ${moves.length} panels...`) + notify.info(t('toasts.recomputingPanels', { count: moves.length })) try { const batchResponse = await recomputeFluxBatch(locationId, { @@ -640,7 +643,7 @@ export function useCanvasInteractions({ } catch { bulkUpdatePanels(moves.map((mv) => ({ id: mv.id, center: mv.prevCenter }))) resetPosition() - notify.error('Failed to recompute group move. Positions reverted.') + notify.error(t('toasts.groupMoveRecomputeFailed')) } finally { setPendingPanelId(null) } @@ -675,7 +678,7 @@ export function useCanvasInteractions({ // Bounds and mask still toast because they are real boundary violations if (placementError === 'overlap') return if (placementError) { - notify.error(getPlacementErrorMessage(placementError)) + notify.error(getPlacementErrorMessage(t, placementError)) return } @@ -796,7 +799,7 @@ export function useCanvasInteractions({ if (!locationId || visiblePanels.length === 0) return setIsModelRecomputing(true) - notify.info('Recalculating energy for new panel dimensions...') + notify.info(t('toasts.recalculatingDimensions')) const nextModel = getPanelModel(nextModelId) ?? PANEL_MODELS[1]! diff --git a/frontend/src/hooks/useOverlayImages.ts b/frontend/src/hooks/useOverlayImages.ts index 5dc1735..a4dd406 100644 --- a/frontend/src/hooks/useOverlayImages.ts +++ b/frontend/src/hooks/useOverlayImages.ts @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { getOverlayUrl } from '@/api/locations' import { notify } from '@/components/ui/toastConfig' +import { useTranslation } from 'react-i18next' function useLoadedImage(src: string | undefined) { const [image, setImage] = useState(null) @@ -73,6 +74,7 @@ export function useOverlayImages( locationId: string | undefined, overlayMode: OverlayMode ) { + const { t } = useTranslation('workbench') const [overlayImageUrl, setOverlayImageUrl] = useState(null) const [isOverlayLoading, setIsOverlayLoading] = useState(false) @@ -94,7 +96,7 @@ export function useOverlayImages( .catch(() => { if (!cancelled) { setOverlayImageUrl(null) - notify.error(`Failed to load ${overlayMode} overlay`) + notify.error(t('toasts.overlayLoadFailed', { mode: overlayMode })) } }) .finally(() => { diff --git a/frontend/src/hooks/useWorkbenchSave.ts b/frontend/src/hooks/useWorkbenchSave.ts index 1a732ee..a2897a5 100644 --- a/frontend/src/hooks/useWorkbenchSave.ts +++ b/frontend/src/hooks/useWorkbenchSave.ts @@ -19,6 +19,7 @@ import { recomputeFluxBatch } from '@/api/locations' import { saveLayout } from '@/api/projects' import { notify } from '@/components/ui/toastConfig' import type { PanelEdit, PanelModel } from '@shared/types' +import { useTranslation } from 'react-i18next' /** * Inputs to `useWorkbenchSave`. @@ -53,6 +54,7 @@ export function useWorkbenchSave({ serializeLayout, updatePanelEnergy }: UseWorkbenchSaveOptions) { + const { t } = useTranslation('workbench') const navigate = useNavigate() const queryClient = useQueryClient() const [isSaving, setIsSaving] = useState(false) @@ -68,7 +70,7 @@ export function useWorkbenchSave({ const serializedLayout = serializeLayout() const activePanels = serializedLayout.filter((panel) => panel.status !== 'deleted') - notify.info(`Recomputing monthly energy for ${activePanels.length} active panels before saving...`) + notify.info(t('toasts.recomputingBeforeSave', { count: activePanels.length })) const batchResponse = await recomputeFluxBatch(locationId, { panels: activePanels.map((panel) => ({ @@ -101,7 +103,7 @@ export function useWorkbenchSave({ }) setIsBatchRecomputing(false) - notify.info('Saving the refreshed layout to your project...') + notify.info(t('toasts.savingLayout')) const updatedProject = await saveLayout(projectId, { editedLayout: nextLayout, selectedPanelModelId }) queryClient.setQueryData(['project', projectId], updatedProject) navigate(`/project/${projectId}/analysis`) diff --git a/frontend/src/locales/en/analysis.json b/frontend/src/locales/en/analysis.json index 52db8a6..dc5420f 100644 --- a/frontend/src/locales/en/analysis.json +++ b/frontend/src/locales/en/analysis.json @@ -465,5 +465,9 @@ "creditForfeiture": "Excess credits are forfeited at the end of each calendar year. No cash payment is made for unused credits.", "systemCost": "System cost is estimated bottom-up: distributor panel pricing + inverter SKU lookup + roof-type-dependent mounting + electrical BOS + permits + labour markup + installer margin. Assumes mid-tier installer pricing and single-storey installation. Typical Malaysian turnkey quotes land within ±10% of this figure. Always confirm with a licensed SEDA-registered installer.", "paybackProjections": "Payback and savings projections do not account for annual maintenance (around RM 500/yr) or inverter replacement (typically needed at year 10 to 15, costing around RM 3,000 to 6,000). Tariff escalation can be configured in Advanced view (default 0%). RP4 revisions in Malaysia have historically trended around 3 to 5% per year. Actual long-term returns may differ." + }, + "toasts": { + "pdfServiceNotConfigured": "PDF export service is not configured", + "pdfExported": "PDF exported: {{filename}}" } } diff --git a/frontend/src/locales/en/workbench.json b/frontend/src/locales/en/workbench.json index 9bf3150..e73a8b0 100644 --- a/frontend/src/locales/en/workbench.json +++ b/frontend/src/locales/en/workbench.json @@ -217,5 +217,18 @@ "step5Body": "Happy with the layout? Click \"Save & Continue\" to move on to the savings analysis. You can always come back to adjust.", "step6Title": "Meet Sol", "step6Body": "Stuck or curious? Tap Sol, your friendly solar guide, to ask anything about this layout, your savings, NEM credits, or how solar works in Malaysia." + }, + "toasts": { + "recomputingPanelYield": "Recomputing panel yield from cached monthly flux data...", + "groupMoveFailed": "Group move failed: {{reason}}", + "recomputingPanels": "Recomputing yield for {{count}} panels...", + "groupMoveRecomputeFailed": "Failed to recompute group move. Positions reverted.", + "recalculatingDimensions": "Recalculating energy for new panel dimensions...", + "recomputingBeforeSave": "Recomputing monthly energy for {{count}} active panels before saving...", + "savingLayout": "Saving the refreshed layout to your project...", + "overlayLoadFailed": "Failed to load {{mode}} overlay", + "placementMask": "That placement leaves the detected roof boundary.", + "placementOverlap": "That placement overlaps another panel.", + "placementBounds": "That placement leaves the roof image bounds." } } diff --git a/frontend/src/locales/ms/analysis.json b/frontend/src/locales/ms/analysis.json index 02a8335..d47d366 100644 --- a/frontend/src/locales/ms/analysis.json +++ b/frontend/src/locales/ms/analysis.json @@ -465,5 +465,9 @@ "creditForfeiture": "Kredit berlebihan dilucutkan pada akhir setiap tahun kalendar. Tiada bayaran tunai dibuat untuk kredit yang tidak digunakan.", "systemCost": "Kos sistem dianggarkan secara bawah ke atas: harga panel pengedar + carian SKU penyongsang + pemasangan bergantung pada jenis bumbung + BOS elektrik + permit + markup buruh + margin pemasang. Menganggap harga pemasang peringkat pertengahan dan pemasangan satu tingkat. Sebut harga turnkey Malaysia yang biasa jatuh dalam julat ±10% daripada angka ini. Sentiasa sahkan dengan pemasang berlesen yang berdaftar dengan SEDA.", "paybackProjections": "Unjuran bayar balik dan penjimatan tidak mengambil kira penyelenggaraan tahunan (sekitar RM 500/thn) atau penggantian penyongsang (biasanya diperlukan pada tahun 10 hingga 15, kos sekitar RM 3,000 hingga 6,000). Eskalasi tarif boleh dikonfigurasi dalam paparan Lanjutan (lalai 0%). Semakan RP4 di Malaysia secara sejarah cenderung sekitar 3 hingga 5% setahun. Pulangan jangka panjang sebenar mungkin berbeza." + }, + "toasts": { + "pdfServiceNotConfigured": "Perkhidmatan eksport PDF belum dikonfigurasikan", + "pdfExported": "PDF dieksport: {{filename}}" } } diff --git a/frontend/src/locales/ms/workbench.json b/frontend/src/locales/ms/workbench.json index 28a7645..e126f21 100644 --- a/frontend/src/locales/ms/workbench.json +++ b/frontend/src/locales/ms/workbench.json @@ -217,5 +217,18 @@ "step5Body": "Berpuas hati dengan susun atur? Klik \"Simpan & Teruskan\" untuk beralih ke analisis penjimatan. Anda sentiasa boleh kembali untuk membuat pelarasan.", "step6Title": "Kenali Sol", "step6Body": "Tersangkut atau ingin tahu lebih lanjut? Tekan Sol, pemandu solar mesra anda, untuk bertanya tentang susun atur ini, penjimatan anda, kredit NEM, atau cara solar berfungsi di Malaysia." + }, + "toasts": { + "recomputingPanelYield": "Mengira semula hasil panel daripada data fluks bulanan tersimpan...", + "groupMoveFailed": "Pemindahan kumpulan gagal: {{reason}}", + "recomputingPanels": "Mengira semula hasil untuk {{count}} panel...", + "groupMoveRecomputeFailed": "Gagal mengira semula pemindahan kumpulan. Kedudukan telah dikembalikan.", + "recalculatingDimensions": "Mengira semula tenaga untuk dimensi panel baharu...", + "recomputingBeforeSave": "Mengira semula tenaga bulanan untuk {{count}} panel aktif sebelum menyimpan...", + "savingLayout": "Menyimpan susun atur yang dikemas kini ke projek anda...", + "overlayLoadFailed": "Gagal memuatkan lapisan {{mode}}", + "placementMask": "Penempatan itu keluar daripada sempadan bumbung yang dikesan.", + "placementOverlap": "Penempatan itu bertindih dengan panel lain.", + "placementBounds": "Penempatan itu keluar daripada sempadan imej bumbung." } } diff --git a/frontend/src/locales/zh/analysis.json b/frontend/src/locales/zh/analysis.json index 301c9c6..69d6d93 100644 --- a/frontend/src/locales/zh/analysis.json +++ b/frontend/src/locales/zh/analysis.json @@ -465,5 +465,9 @@ "creditForfeiture": "多余积分在每个日历年末失效,未使用的积分不予现金支付。", "systemCost": "系统成本自下而上估算:经销商太阳能板定价 + 逆变器型号查询 + 因屋顶类型而异的支架费 + 电气 BOS + 报批费 + 人工加成 + 安装商利润。假设中端安装商定价和单层楼安装。马来西亚典型交钥匙报价通常在此数字的 ±10% 范围内,请务必向持牌 SEDA 注册安装商确认。", "paybackProjections": "回本期和节省预测不包含年度维护费(约 RM 500/年)或逆变器更换费(通常在第 10 至 15 年需要,费用约 RM 3,000 至 6,000)。电价涨幅可在高级模式中配置(默认 0%),马来西亚 RP4 历次调整约在每年 3 至 5% 左右,实际长期回报可能有所不同。" + }, + "toasts": { + "pdfServiceNotConfigured": "PDF 导出服务尚未配置", + "pdfExported": "PDF 已导出:{{filename}}" } } diff --git a/frontend/src/locales/zh/workbench.json b/frontend/src/locales/zh/workbench.json index 5fc75b0..d4a756a 100644 --- a/frontend/src/locales/zh/workbench.json +++ b/frontend/src/locales/zh/workbench.json @@ -217,5 +217,18 @@ "step5Body": "对布局满意了吗?点击「保存并继续」进入节省分析。您随时可以返回调整。", "step6Title": "认识 Sol", "step6Body": "卡住或想了解更多?点击 Sol,你的太阳能小帮手,可以问任何关于这个布局、节省金额、NEM 净电量积分,或马来西亚太阳能运作方式的问题。" + }, + "toasts": { + "recomputingPanelYield": "正在依据已缓存的月度辐照数据重新计算板面发电量……", + "groupMoveFailed": "批量移动失败:{{reason}}", + "recomputingPanels": "正在重新计算 {{count}} 块板的发电量……", + "groupMoveRecomputeFailed": "批量移动重新计算失败,位置已还原。", + "recalculatingDimensions": "正在按新的板面尺寸重新计算发电量……", + "recomputingBeforeSave": "保存前正在重新计算 {{count}} 块启用板的月度发电量……", + "savingLayout": "正在将更新后的布局保存到您的项目……", + "overlayLoadFailed": "加载{{mode}}图层失败", + "placementMask": "该位置超出了检测到的屋顶范围。", + "placementOverlap": "该位置与另一块板重叠。", + "placementBounds": "该位置超出了屋顶图像范围。" } }