충돌 해결: iOS 네이티브 브릿지와 로딩 스피너 통합
- iOS 환경에서 네이티브 브릿지로 이미지 저장
- 로딩 스피너 유지
- 파일명 형식 통일 (cash-receipt-YYMMDDHHmmss.png)
- 다운로드 후 자동으로 팝업 닫기

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jay Sheen
2025-11-12 14:42:19 +09:00
9 changed files with 215 additions and 121 deletions

View File

@@ -78,6 +78,7 @@
"react-router": "^7.8.2", "react-router": "^7.8.2",
"react-router-dom": "^7.8.2", "react-router-dom": "^7.8.2",
"react-slidedown": "^2.4.7", "react-slidedown": "^2.4.7",
"react-spinners": "^0.17.0",
"react-tabs": "^6.1.0", "react-tabs": "^6.1.0",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"react-tooltip": "^5.29.1", "react-tooltip": "^5.29.1",

14
pnpm-lock.yaml generated
View File

@@ -209,6 +209,9 @@ importers:
react-slidedown: react-slidedown:
specifier: ^2.4.7 specifier: ^2.4.7
version: 2.4.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 2.4.7(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react-spinners:
specifier: ^0.17.0
version: 0.17.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react-tabs: react-tabs:
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.0(react@19.1.1) version: 6.1.0(react@19.1.1)
@@ -4829,6 +4832,12 @@ packages:
react: ^16.3.0 || 17 react: ^16.3.0 || 17
react-dom: ^16.3.0 || 17 react-dom: ^16.3.0 || 17
react-spinners@0.17.0:
resolution: {integrity: sha512-L/8HTylaBmIWwQzIjMq+0vyaRXuoAevzWoD35wKpNTxxtYXWZp+xtgkfD7Y4WItuX0YvdxMPU79+7VhhmbmuTQ==}
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-tabs@6.1.0: react-tabs@6.1.0:
resolution: {integrity: sha512-6QtbTRDKM+jA/MZTTefvigNxo0zz+gnBTVFw2CFVvq+f2BuH0nF0vDLNClL045nuTAdOoK/IL1vTP0ZLX0DAyQ==} resolution: {integrity: sha512-6QtbTRDKM+jA/MZTTefvigNxo0zz+gnBTVFw2CFVvq+f2BuH0nF0vDLNClL045nuTAdOoK/IL1vTP0ZLX0DAyQ==}
peerDependencies: peerDependencies:
@@ -11218,6 +11227,11 @@ snapshots:
react-dom: 19.1.1(react@19.1.1) react-dom: 19.1.1(react@19.1.1)
tslib: 2.8.1 tslib: 2.8.1
react-spinners@0.17.0(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
react-tabs@6.1.0(react@19.1.1): react-tabs@6.1.0(react@19.1.1):
dependencies: dependencies:
clsx: 2.1.1 clsx: 2.1.1

View File

@@ -3,11 +3,20 @@ import { snackBar, appBridge } from '@/shared/lib';
import { toPng } from 'html-to-image'; import { toPng } from 'html-to-image';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import '@/shared/ui/assets/css/style-tax-invoice.css'; import '@/shared/ui/assets/css/style-tax-invoice.css';
import { useEffect } from 'react'; import { useEffect, useState, CSSProperties } from 'react';
import { ClipLoader, FadeLoader } from 'react-spinners';
import { NumericFormat } from 'react-number-format'; import { NumericFormat } from 'react-number-format';
import { AmountInfo, CustomerInfo, IssueInfo, MerchantInfo, ProductInfo, TransactionInfo } from '@/entities/transaction/model/types'; import { AmountInfo, CustomerInfo, IssueInfo, MerchantInfo, ProductInfo, TransactionInfo } from '@/entities/transaction/model/types';
import moment from 'moment'; import moment from 'moment';
const override: CSSProperties = {
position: 'fixed',
display: 'block',
margin: '0 auto',
top: '50%',
left: '50%',
};
export interface CashReceiptSampleProps { export interface CashReceiptSampleProps {
cashReceiptSampleOn: boolean; cashReceiptSampleOn: boolean;
setCashReceiptSampleOn: (cashReceiptSampleOn: boolean) => void; setCashReceiptSampleOn: (cashReceiptSampleOn: boolean) => void;
@@ -29,6 +38,9 @@ export const CashReceiptSample = ({
customerInfo, customerInfo,
productInfo productInfo
}: CashReceiptSampleProps) => { }: CashReceiptSampleProps) => {
let [loading, setLoading] = useState<boolean>(false);
let [color, setColor] = useState<string>('#0b0606');
const { t } = useTranslation(); const { t } = useTranslation();
const downloadImage = async () => { const downloadImage = async () => {
@@ -36,22 +48,24 @@ export const CashReceiptSample = ({
try { try {
const imageDataUrl = await toPng(section); const imageDataUrl = await toPng(section);
const fileName = 'cash-receipt-' + moment().format('YYMMDDHHmmss');
// iOS 네이티브 환경인 경우 네이티브 브릿지 사용 // iOS 네이티브 환경인 경우 네이티브 브릿지 사용
if (appBridge.isIOS()) { if (appBridge.isIOS()) {
try { try {
// data:image/png;base64, 부분 제거하고 순수 base64 데이터만 추출 // data:image/png;base64, 부분 제거하고 순수 base64 데이터만 추출
const base64Data = imageDataUrl.split(',')[1]; const base64Data = imageDataUrl.split(',')[1];
const fileName = `cash_receipt_${moment().format('YYYYMMDDHHmmss')}.png`;
const result = await appBridge.saveImage({ const result = await appBridge.saveImage({
imageData: base64Data, imageData: base64Data,
fileName: fileName, fileName: fileName + '.png',
mimeType: 'image/png' mimeType: 'image/png'
}); });
if (result.success) { if (result.success) {
snackBar(t('common.imageSaved')); snackBar(t('common.imageSaved'), function(){
onClickToClose();
});
} else { } else {
throw new Error(result.error || 'Failed to save image'); throw new Error(result.error || 'Failed to save image');
} }
@@ -62,14 +76,18 @@ export const CashReceiptSample = ({
} else { } else {
// Android 또는 웹 환경인 경우 기존 방식 사용 (다운로드 링크) // Android 또는 웹 환경인 경우 기존 방식 사용 (다운로드 링크)
const link = document.createElement('a'); const link = document.createElement('a');
link.download = `cash_receipt_${moment().format('YYYYMMDDHHmmss')}.png`; link.download = fileName + '.png';
link.href = imageDataUrl; link.href = imageDataUrl + '#' + fileName + '.png';
link.click(); link.click();
snackBar(t('common.imageRequested')); snackBar(t('common.imageRequested'), function(){
onClickToClose();
});
} }
} catch (error) { } catch (error) {
console.error('Image generation failed:', error); console.error('Image generation failed:', error);
snackBar(t('common.imageGenerationFailed')); snackBar(t('common.imageGenerationFailed'));
} finally {
setLoading(false);
} }
}; };
const onClickToClose = () => { const onClickToClose = () => {
@@ -95,6 +113,7 @@ export const CashReceiptSample = ({
useEffect(() => { useEffect(() => {
if(!!cashReceiptSampleOn){ if(!!cashReceiptSampleOn){
setLoading(true);
setTimeout(() => { setTimeout(() => {
downloadImage(); downloadImage();
}, 300); }, 300);
@@ -270,6 +289,21 @@ export const CashReceiptSample = ({
</div> </div>
</div> </div>
</div> </div>
{ !!loading &&
<>
<div
className="bg-dim"
style={{ opacity: '0.4' }}
></div>
<FadeLoader
color={ color }
loading={ loading }
cssOverride={ override }
aria-label="Loading Spinner"
data-testid="loader"
/>
</>
}
</> </>
); );
}; };

View File

@@ -3,119 +3,152 @@ import { snackBar } from '@/shared/lib';
import { toPng } from 'html-to-image'; import { toPng } from 'html-to-image';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import '@/shared/ui/assets/css/style-tax-invoice.css'; import '@/shared/ui/assets/css/style-tax-invoice.css';
import { useEffect } from 'react'; import { ClipLoader, FadeLoader } from 'react-spinners';
import { CSSProperties, useEffect, useState } from 'react';
import { NumericFormat } from 'react-number-format'; import { NumericFormat } from 'react-number-format';
import { DepositInfo } from '@/entities/transaction/model/types'; import { DepositInfo } from '@/entities/transaction/model/types';
import moment from 'moment';
export interface DepositReceiptSampleProps { export interface DepositReceiptSampleProps {
depositReceiptSampleOn: boolean; depositReceiptSampleOn: boolean;
setDepositReceiptSampleOn: (DepositReceiptSampleOn: boolean) => void; setDepositReceiptSampleOn: (DepositReceiptSampleOn: boolean) => void;
depositInfo?: DepositInfo depositInfo?: DepositInfo
}; };
const override: CSSProperties = {
position: 'fixed',
display: 'block',
margin: '0 auto',
top: '50%',
left: '50%',
zIndex: 2000
};
export const DepositReceiptSample = ({ export const DepositReceiptSample = ({
depositReceiptSampleOn, depositReceiptSampleOn,
setDepositReceiptSampleOn, setDepositReceiptSampleOn,
depositInfo depositInfo
}: DepositReceiptSampleProps) => { }: DepositReceiptSampleProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
let [loading, setLoading] = useState<boolean>(false);
let [color, setColor] = useState<string>('#0b0606');
const downloadImage = () => { const downloadImage = () => {
const section = document.getElementById('image-section') as HTMLElement const section = document.getElementById('image-section') as HTMLElement
toPng(section).then((image) => { toPng(section).then((image) => {
const link = document.createElement('a'); const link = document.createElement('a');
link.download = 'downloadImage.png'; let fileName = 'receipt-confirmation-' + moment().format('YYMMDDHHmmss');
link.href = image; link.download = fileName + '.png';
link.click(); link.href = image + '#' + fileName + '.png';
snackBar(t('common.imageRequested'), () => { link.click();
onClickToClose(); snackBar(t('common.imageRequested'), () => {
}); onClickToClose();
}); });
} }).finally(() => {
const onClickToClose = () => { setLoading(false);
setDepositReceiptSampleOn(false); });
}; }
const onClickToClose = () => {
setDepositReceiptSampleOn(false);
};
useEffect(() => { useEffect(() => {
if(!!depositReceiptSampleOn){ if (!!depositReceiptSampleOn) {
setTimeout(() => { setLoading(true);
downloadImage(); setTimeout(() => {
}, 300); downloadImage();
} }, 300);
}, [depositReceiptSampleOn]); }
}, [depositReceiptSampleOn, depositInfo]);
return (
<> return (
<div className="mail-page"> <>
<div id="image-section"> <div className="mail-page">
<div className="wrap"> <div id="image-section">
<div className="header"> <div className="wrap">
<div className="header-top"> <div className="header">
<img <div className="header-top">
className="logo" <img
src={ IMAGE_ROOT + '/mail_nicepay_logo.svg' } className="logo"
alt="NICEPAY" src={IMAGE_ROOT + '/mail_nicepay_logo.svg'}
/> alt="NICEPAY"
</div> />
<div className="divider"></div> </div>
<div className="title-row"> <div className="divider"></div>
<div className="title"></div> <div className="title-row">
</div> <div className="title"></div>
</div> </div>
<div className="section"> </div>
<div className="head"> <div className="section">
<div className="icon"> <div className="head">
<svg <div className="icon">
xmlns="http://www.w3.org/2000/svg" <svg
width="16" xmlns="http://www.w3.org/2000/svg"
height="20" width="16"
viewBox="0 0 16 20" height="20"
fill="none" viewBox="0 0 16 20"
> fill="none"
<path d="M13.7656 0.799805C14.839 0.800034 15.7001 1.70434 15.7002 2.7998V17.2002C15.7001 18.2957 14.839 19.2 13.7656 19.2002H2.23438C1.16097 19.2 0.299907 18.2957 0.299805 17.2002V2.7998C0.299907 1.70434 1.16097 0.800034 2.23438 0.799805H13.7656ZM2.23438 2.40039C2.03318 2.40062 1.85655 2.57273 1.85645 2.7998V17.2002C1.85655 17.4273 2.03318 17.5994 2.23438 17.5996H13.7656C13.9674 17.5994 14.1435 17.4274 14.1436 17.2002V2.7998C14.1435 2.57257 13.9674 2.40062 13.7656 2.40039H2.23438Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" /> >
<path d="M11.9355 3.7998C12.3344 3.79989 12.7002 4.09119 12.7002 4.5C12.7002 4.90881 12.3344 5.20011 11.9355 5.2002H4.06445C3.66564 5.20011 3.2998 4.90881 3.2998 4.5C3.2998 4.09119 3.66564 3.79989 4.06445 3.7998H11.9355Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" /> <path d="M13.7656 0.799805C14.839 0.800034 15.7001 1.70434 15.7002 2.7998V17.2002C15.7001 18.2957 14.839 19.2 13.7656 19.2002H2.23438C1.16097 19.2 0.299907 18.2957 0.299805 17.2002V2.7998C0.299907 1.70434 1.16097 0.800034 2.23438 0.799805H13.7656ZM2.23438 2.40039C2.03318 2.40062 1.85655 2.57273 1.85645 2.7998V17.2002C1.85655 17.4273 2.03318 17.5994 2.23438 17.5996H13.7656C13.9674 17.5994 14.1435 17.4274 14.1436 17.2002V2.7998C14.1435 2.57257 13.9674 2.40062 13.7656 2.40039H2.23438Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" />
<path d="M11.9355 6.7998C12.3344 6.79989 12.7002 7.09119 12.7002 7.5C12.7002 7.90881 12.3344 8.20011 11.9355 8.2002H4.06445C3.66564 8.20011 3.2998 7.90881 3.2998 7.5C3.2998 7.09119 3.66564 6.79989 4.06445 6.7998H11.9355Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" /> <path d="M11.9355 3.7998C12.3344 3.79989 12.7002 4.09119 12.7002 4.5C12.7002 4.90881 12.3344 5.20011 11.9355 5.2002H4.06445C3.66564 5.20011 3.2998 4.90881 3.2998 4.5C3.2998 4.09119 3.66564 3.79989 4.06445 3.7998H11.9355Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" />
<path d="M6.91504 9.7998C7.31807 9.7998 7.7002 10.085 7.7002 10.5C7.7002 10.9157 7.31789 11.2002 6.91504 11.2002H4.08496C3.68193 11.2002 3.2998 10.915 3.2998 10.5C3.2998 10.085 3.68193 9.7998 4.08496 9.7998H6.91504Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" /> <path d="M11.9355 6.7998C12.3344 6.79989 12.7002 7.09119 12.7002 7.5C12.7002 7.90881 12.3344 8.20011 11.9355 8.2002H4.06445C3.66564 8.20011 3.2998 7.90881 3.2998 7.5C3.2998 7.09119 3.66564 6.79989 4.06445 6.7998H11.9355Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" />
<path d="M10.9414 14.7998C11.3391 14.7998 11.7002 15.093 11.7002 15.5C11.7002 15.907 11.3391 16.2002 10.9414 16.2002H6.05859C5.66094 16.2002 5.2998 15.907 5.2998 15.5C5.2998 15.093 5.66094 14.7998 6.05859 14.7998H10.9414Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" /> <path d="M6.91504 9.7998C7.31807 9.7998 7.7002 10.085 7.7002 10.5C7.7002 10.9157 7.31789 11.2002 6.91504 11.2002H4.08496C3.68193 11.2002 3.2998 10.915 3.2998 10.5C3.2998 10.085 3.68193 9.7998 4.08496 9.7998H6.91504Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" />
</svg> <path d="M10.9414 14.7998C11.3391 14.7998 11.7002 15.093 11.7002 15.5C11.7002 15.907 11.3391 16.2002 10.9414 16.2002H6.05859C5.66094 16.2002 5.2998 15.907 5.2998 15.5C5.2998 15.093 5.66094 14.7998 6.05859 14.7998H10.9414Z" fill="#2D3436" stroke="#2D3436" strokeWidth="0.4" />
</div> </svg>
<div className="h-title"></div> </div>
</div> <div className="h-title"></div>
<div className="row"> </div>
<div className="k"></div> <div className="row">
<div className="v">{ depositInfo?.depositDate}</div> <div className="k"></div>
</div> <div className="v">{depositInfo?.depositDate}</div>
<div className="row"> </div>
<div className="k"></div> <div className="row">
<div className="v">{ depositInfo?.depositBank }</div> <div className="k"></div>
</div> <div className="v">{depositInfo?.depositBank}</div>
<div className="row"> </div>
<div className="k"></div> <div className="row">
<div className="v">{ depositInfo?.depositAccount }</div> <div className="k"></div>
</div> <div className="v">{depositInfo?.depositAccount}</div>
<div className="row"> </div>
<div className="k"></div> <div className="row">
<div className="v"> <div className="k"></div>
<NumericFormat <div className="v">
value={ depositInfo?.amount } <NumericFormat
thousandSeparator value={depositInfo?.amount}
displayType='text' thousandSeparator
suffix='' displayType='text'
></NumericFormat> suffix='원'
</div> ></NumericFormat>
</div> </div>
<div className="row"> </div>
<div className="k"></div> <div className="row">
<div className="v">{ depositInfo?.depositReason }</div> <div className="k"></div>
</div> <div className="v">{depositInfo?.depositReason}</div>
<div className="row"> </div>
<div className="k">ID</div> <div className="row">
<div className="v">{ depositInfo?.depositId }</div> <div className="k">ID</div>
</div> <div className="v">{depositInfo?.depositId}</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</> </div>
); {!!loading &&
<>
<div
className="bg-dim"
style={{zIndex: 1500, opacity: '0.4' }}
></div>
<FadeLoader
color={color}
loading={loading}
cssOverride={override}
aria-label="Loading Spinner"
data-testid="loader"
/>
</>
}
</>
);
}; };

View File

@@ -194,7 +194,7 @@ export const ListItem = ({
rs.push( rs.push(
<div <div
className="transaction-details" className="transaction-details"
key={ tid } key={ tid + 'details' }
> >
<span>{ getTime() }</span> <span>{ getTime() }</span>
<span className="separator">|</span> <span className="separator">|</span>
@@ -213,7 +213,10 @@ export const ListItem = ({
} }
else if(transactionCategory === TransactionCategory.CashReceipt){ else if(transactionCategory === TransactionCategory.CashReceipt){
rs.push( rs.push(
<div className="transaction-details"> <div
className="transaction-details"
key={ tid + 'details' }
>
<span>{ getTime() }</span> <span>{ getTime() }</span>
<span className="separator">|</span> <span className="separator">|</span>
<span>{ transactionType }</span> <span>{ transactionType }</span>
@@ -226,7 +229,10 @@ export const ListItem = ({
} }
else if(transactionCategory === TransactionCategory.Escrow){ else if(transactionCategory === TransactionCategory.Escrow){
rs.push( rs.push(
<div className="transaction-details"> <div
className="transaction-details"
key={ tid + 'details' }
>
<span>{ getTime() }</span> <span>{ getTime() }</span>
<span className="separator">|</span> <span className="separator">|</span>
<span>{ deliveryStatus }</span> <span>{ deliveryStatus }</span>
@@ -246,7 +252,10 @@ export const ListItem = ({
} }
else if(transactionCategory === TransactionCategory.Billing){ else if(transactionCategory === TransactionCategory.Billing){
rs.push( rs.push(
<div className="transaction-details"> <div
className="transaction-details"
key={ tid + 'details' }
>
<span>{ getTime() }</span> <span>{ getTime() }</span>
<span className="separator">|</span> <span className="separator">|</span>
<span>{ processResult }</span> <span>{ processResult }</span>

View File

@@ -216,7 +216,9 @@ export const AmountInfoSection = ({
}; };
cashReceiptReceiptSendEamil(params).then((rs: CashReceiptReceiptSendEmailResponse) => { cashReceiptReceiptSendEamil(params).then((rs: CashReceiptReceiptSendEmailResponse) => {
console.log(rs); console.log(rs);
snackBar('이메일로 현금영수증 요청이 완료되었습니다.'); if(rs.message){
snackBar(rs.message);
}
}).catch((e: any) => { }).catch((e: any) => {
if(e.response?.data?.error?.message){ if(e.response?.data?.error?.message){
snackBar(e.response?.data?.error?.message); snackBar(e.response?.data?.error?.message);

View File

@@ -3,8 +3,8 @@ import { API_URL_VAT_RETURN } from '@/shared/api/api-url-vat-return';
import { resultify } from '@/shared/lib/resultify'; import { resultify } from '@/shared/lib/resultify';
import { NiceAxiosError } from '@/shared/@types/error'; import { NiceAxiosError } from '@/shared/@types/error';
import { import {
VatReturnTaxInvoiceSendEmailResponse,
VatReturnTaxInvoiceSendEmailParams, VatReturnTaxInvoiceSendEmailParams,
VatReturnTaxInvoiceSendEmailResponse
} from '../model/types'; } from '../model/types';
import { import {
useMutation, useMutation,

View File

@@ -55,7 +55,9 @@ export const AmountSection = ({
}; };
vatReturnTaxInvoiceSendEmail(params).then((rs: VatReturnTaxInvoiceSendEmailResponse) => { vatReturnTaxInvoiceSendEmail(params).then((rs: VatReturnTaxInvoiceSendEmailResponse) => {
console.log(rs); console.log(rs);
snackBar('이메일로 세금계산서 요청이 완료되었습니다.'); if(rs.message){
snackBar(rs.message);
}
}).catch((e: any) => { }).catch((e: any) => {
if(e.response?.data?.error?.message){ if(e.response?.data?.error?.message){
snackBar(e.response?.data?.error?.message); snackBar(e.response?.data?.error?.message);

View File

@@ -117,11 +117,10 @@ export const HomePage = () => {
else{ else{
useStore.getState().UserStore.setFirstAccess(false); useStore.getState().UserStore.setFirstAccess(false);
} }
let userFavorite = useStore.getState().UserStore.userFavorite; let userFavorite = useStore.getState().UserStore.userFavorite;
setFavoriteItems(userFavorite); setFavoriteItems(userFavorite);
callHomeBannerList(); callHomeBannerList();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const setBottomBannerEffect = (mode: boolean) => { const setBottomBannerEffect = (mode: boolean) => {