Skip to content

Commit 06e87f9

Browse files
committed
feat: Add rescue guide download feature
1 parent df4dc9a commit 06e87f9

14 files changed

Lines changed: 152 additions & 130 deletions

File tree

apps/backend/document-service/package.json

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,23 @@
11
{
2-
"name": "document-service",
3-
"main": "build/main.js",
2+
"private": "true",
3+
"name": "@pawhaven/document-service",
44
"version": "1.0.0",
55
"scripts": {
6-
"start:dev": "cross-env NODE_ENV=dev nest start --watch",
7-
"stary:uat": "cross-env NODE_ENV=uat nest start --watch",
8-
"build": "rm -rf build && nest build"
6+
"postinstall": "pnpm run prisma:generate-mongodb",
7+
"dev": "nest start --watch",
8+
"build": "nest build",
9+
"install:core-service": "pnpm --filter=@pawhaven/document-service... install",
10+
"turbo:build": "turbo run build --filter=@pawhaven/document-service --env-mode=loose",
11+
"start": "node dist/main.js",
12+
"typecheck": "tsc --noEmit"
913
},
1014
"dependencies": {
1115
"@nestjs-modules/mailer": "^2.0.2",
1216
"@nestjs/axios": "^4.0.1",
1317
"@nestjs/common": "^11.1.6",
1418
"@nestjs/config": "^4.0.2",
1519
"@nestjs/core": "^11.1.6",
16-
"@nestjs/jwt": "^11.0.1",
17-
"@nestjs/microservices": "^11.1.6",
18-
"@nestjs/mongoose": "^11.0.3",
1920
"@nestjs/platform-express": "^11.1.6",
20-
"@nestjs/serve-static": "^5.0.4",
21-
"@nestjs/swagger": "^11.2.0",
22-
"@nestjs/terminus": "^11.0.0",
2321
"@nestjs/throttler": "^6.4.0",
2422
"@react-email/components": "^0.5.6",
2523
"@types/express": "^5.0.3",
@@ -31,14 +29,11 @@
3129
"class-validator": "^0.14.2",
3230
"cookie-parser": "^1.4.7",
3331
"cross-env": "^10.1.0",
34-
"crypto-js": "^4.2.0",
3532
"csurf": "^1.11.0",
3633
"dayjs": "^1.11.18",
3734
"dotenv": "^17.2.3",
38-
"helmet": "^8.1.0",
3935
"i18n": "^0.15.2",
4036
"js-yaml": "^4.1.0",
41-
"mongoose": "^8.19.1",
4237
"nodemailer": "^7.0.11",
4338
"puppeteer": "^24.24.0",
4439
"react": "^19.2.0",
@@ -53,15 +48,12 @@
5348
"@pawhaven/eslint-config": "workspace:*",
5449
"@nestjs/cli": "^11.0.10",
5550
"@nestjs/schematics": "^11.0.9",
56-
"@nestjs/testing": "^11.1.6",
5751
"@types/i18n": "^0.13.12",
58-
"@types/jest": "^30.0.0",
5952
"@types/js-yaml": "^4.0.9",
6053
"@types/node": "^24.7.1",
6154
"@types/nodemailer": "^6.4.20",
6255
"@types/passport-local": "^1.0.38",
6356
"@types/styled-components": "^5.1.34",
64-
"@types/supertest": "^6.0.3",
6557
"source-map-support": "^0.5.21",
6658
"supertest": "^7.1.4",
6759
"ts-loader": "^9.5.4",
Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,7 @@
11
{
22
"extends": "@pawhaven/tsconfig/node",
33
"compilerOptions": {
4-
"jsx": "react-jsx",
5-
"outDir": "build",
6-
"paths": {
7-
"@shared/*": ["../../../packages/backend-core/*"],
8-
"@modules/*": ["./src/modules/*"]
9-
}
4+
"outDir": "dist"
105
},
116
"include": ["src"]
127
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { apiClient } from '@/utils/apiClient';
2+
3+
export const getRescueGuideDocs = () => {
4+
return apiClient.download('/document/rescue/guide');
5+
};

apps/frontend/portal/src/features/RescueGuide/index.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { Box, Container, Typography } from '@mui/material';
1+
import { Alert, Box, Container, Typography } from '@mui/material';
2+
import { FileDownloadButton } from '@pawhaven/frontend-core';
3+
import { showToast } from '@pawhaven/ui';
24
import { ArrowDownToLine } from 'lucide-react';
35
import { useTranslation } from 'react-i18next';
46

7+
import { getRescueGuideDocs } from './apis/request';
58
import { StepCard } from './components/StepCard';
69

710
export const RescueGuide = () => {
@@ -38,10 +41,20 @@ export const RescueGuide = () => {
3841
</Box>
3942

4043
<p className="flex justify-center m-6">
41-
<button className="flex items-center text-2xl bold cursor-pointer p-4 bg-surface rounded-2xl">
44+
<FileDownloadButton
45+
fileFetchRequest={getRescueGuideDocs}
46+
onError={() => {
47+
showToast({
48+
type: 'error',
49+
message: t('rescueGuide.download_failed'),
50+
});
51+
}}
52+
fileType="PDF"
53+
contentClassName="flex items-center text-2xl bold cursor-pointer p-4 rounded-2xl"
54+
>
4255
<ArrowDownToLine />
4356
<span>{t('rescueGuide.download_guide')}</span>
44-
</button>
57+
</FileDownloadButton>
4558
</p>
4659
</Container>
4760
</div>

packages/frontend-core/src/api/index.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getLocale } from '../utils/locale/getLocale';
1111
import { getUTCTimestamp } from './encrypt';
1212
import { normalizeHttpError } from './errorHandle';
1313
import type { ApiClientOptions } from './types';
14+
import { RequestMode } from './types';
1415

1516
/**
1617
* Configuration options for creating an API client instance.
@@ -20,7 +21,14 @@ import type { ApiClientOptions } from './types';
2021
* Factory function to create a reusable API client with common interceptors and headers.
2122
*/
2223
export const createApiClient = (options: ApiClientOptions) => {
23-
const { baseURL, timeout = 20000, withCredentials = true } = options;
24+
const {
25+
baseURL = '/api',
26+
timeout = 20000,
27+
withCredentials = true,
28+
requestMode = RequestMode.http,
29+
} = options as ApiClientOptions & {
30+
requestMode?: keyof typeof RequestMode;
31+
};
2432

2533
const Http: AxiosInstance = axios.create({
2634
baseURL,
@@ -31,10 +39,14 @@ export const createApiClient = (options: ApiClientOptions) => {
3139
const getHttpHeaders = () => {
3240
const timestamp = `${getUTCTimestamp()}`;
3341
const headers: Record<string, any> = {
34-
Accept: 'application/json',
3542
'X-timestamp': timestamp,
3643
'X-locale': getLocale(),
3744
};
45+
46+
if (requestMode === RequestMode.http) {
47+
headers.Accept = 'application/json';
48+
}
49+
3850
return headers;
3951
};
4052

@@ -52,13 +64,18 @@ export const createApiClient = (options: ApiClientOptions) => {
5264
// ✅ Response interceptor
5365
Http.interceptors.response.use(
5466
(response: AxiosResponse<any>) => {
67+
if (requestMode === RequestMode.resource) {
68+
return response;
69+
}
70+
5571
if (
5672
response?.data?.status >= 200 &&
5773
response?.data?.status < 400 &&
5874
response?.data?.isSuccess
5975
) {
6076
return response.data.data;
6177
}
78+
6279
return Promise.reject(normalizeHttpError(response.data));
6380
},
6481
(error) => {
@@ -95,5 +112,12 @@ export const createApiClient = (options: ApiClientOptions) => {
95112
): Promise<T> {
96113
return Http.put(url, data, { ...config });
97114
},
115+
download(url: string, config?: AxiosRequestConfig): Promise<Blob> {
116+
return Http.get(url, {
117+
responseType: 'blob',
118+
transformResponse: (r) => r,
119+
...config,
120+
}).then((res: AxiosResponse<Blob>) => res.data);
121+
},
98122
};
99123
};

packages/frontend-core/src/api/types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,18 @@ export const httpRequestErrors = {
1313
NETWORK: 'NETWORK',
1414
} as const;
1515

16+
export const RequestMode = {
17+
http: 'http',
18+
resource: 'resource',
19+
} as const;
20+
1621
export interface ApiClientOptions {
17-
baseURL: string; // The base URL for API requests
22+
baseURL?: string; // The base URL for API requests
1823
timeout?: number; // Optional request timeout
1924
enableSign?: boolean; // Whether to use signature validation
20-
prefix: string; // endpoint prefix
25+
prefix?: string; // endpoint prefix
2126
withCredentials?: boolean; // Is send cookies to backend automatically
27+
requestMode?: keyof typeof RequestMode;
2228
}
2329

2430
export type HttpRequestErrorType =
@@ -39,6 +45,7 @@ export interface ApiResponseType {
3945
status: number;
4046
code: string;
4147
}
48+
4249
export enum extraRequestHeader {
4350
'access-token' = 'access-token',
4451
refreshToken = 'refreshToken',
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import Button from '@mui/material/Button';
2+
import CircularProgress from '@mui/material/CircularProgress';
3+
import type { ReactNode } from 'react';
4+
import { useCallback, useState } from 'react';
5+
6+
export interface FileDownloadButtonProps {
7+
fileFetchRequest: () => Promise<Blob>;
8+
fileName?: string;
9+
fileType: string;
10+
label?: string | ReactNode;
11+
disabled?: boolean;
12+
onSuccess?: (blob: Blob, fileType: string) => void;
13+
onError?: (error: unknown, fileType: string) => void;
14+
children?: ReactNode;
15+
buttonClassName?: string;
16+
contentClassName?: string;
17+
}
18+
19+
/**
20+
* FileDownloadButton
21+
*
22+
* Reusable button for downloading arbitrary files with loading state.
23+
* Explicit file type is required to make download intent clear to callers.
24+
*/
25+
export const FileDownloadButton = ({
26+
fileFetchRequest,
27+
fileName,
28+
fileType,
29+
label,
30+
disabled = false,
31+
onSuccess,
32+
onError,
33+
children,
34+
buttonClassName,
35+
contentClassName,
36+
}: FileDownloadButtonProps) => {
37+
const [isDownloading, setIsDownloading] = useState(false);
38+
39+
const handleDownload = useCallback(async () => {
40+
if (!fileFetchRequest) return;
41+
42+
try {
43+
setIsDownloading(true);
44+
45+
const blob = await fileFetchRequest();
46+
47+
const objectUrl = window.URL.createObjectURL(blob);
48+
49+
const anchor = document.createElement('a');
50+
anchor.href = objectUrl;
51+
if (fileName) {
52+
anchor.download = fileName;
53+
}
54+
anchor.click();
55+
window.URL.revokeObjectURL(objectUrl);
56+
onSuccess?.(blob, fileType);
57+
} catch (error) {
58+
onError?.(error, fileType);
59+
// In real projects, replace this with centralized error reporting (e.g. Sentry)
60+
console.error('[FileDownloadButton] Download failed:', error);
61+
} finally {
62+
setIsDownloading(false);
63+
}
64+
}, [fileFetchRequest, fileName, fileType, onSuccess, onError]);
65+
66+
return (
67+
<Button
68+
variant="contained"
69+
onClick={handleDownload}
70+
disabled={disabled || isDownloading}
71+
startIcon={isDownloading ? <CircularProgress size={16} /> : null}
72+
className={buttonClassName}
73+
>
74+
<span className={contentClassName}>{children ?? label}</span>
75+
</Button>
76+
);
77+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { I18nSwitch } from './I18nSwitch';
2+
export { FileDownloadButton } from './FileDownloadButton';

packages/i18n/locales/de-DE.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@
158158
"desc": "Teile deine Rettungsgeschichte auf PawHaven, damit mehr Menschen helfen können."
159159
}
160160
],
161-
"download_guide": "Laden Sie die Details des Rettungsleitfadens herunter"
161+
"download_guide": "Laden Sie die Details des Rettungsleitfadens herunter",
162+
"download_failed": "Download fehlgeschlagen. Bitte versuchen Sie es erneut."
162163
},
163164
"errorMessage": {
164165
"TOKEN_EXPIRED": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",

packages/i18n/locales/en-US.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@
158158
"desc": "Record and share your rescue story on PawHaven to help connect more volunteers."
159159
}
160160
],
161-
"download_guide": "Download the details of the rescue guide"
161+
"download_guide": "Download the details of the rescue guide",
162+
"download_failed": "Download failed. Please try again."
162163
},
163164
"errorMessage": {
164165
"TOKEN_EXPIRED": "Your session has expired. Please log in again.",

0 commit comments

Comments
 (0)