Compare commits

...

3 Commits

17 changed files with 785 additions and 794 deletions

View File

@ -4023,11 +4023,11 @@
}
.url-input-logo {
margin-bottom: 40px;
margin-bottom: 0px;
}
.url-input-logo img {
height: 48px;
height: 70px;
width: auto;
}
@ -4113,6 +4113,27 @@
cursor: not-allowed;
}
.url-input-manual-button {
margin: auto;
max-width: 400px;
width: 100%;
padding: 11px 16px;
border-radius: 12px;
border: 1px solid rgba(155, 202, 204, 0.35);
background: transparent;
color: rgba(155, 202, 204, 0.8);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-normal);
}
.url-input-manual-button:hover {
border-color: #9BCACC;
color: #9BCACC;
background: rgba(155, 202, 204, 0.06);
}
.url-input-error {
color: #F56565;
font-family: 'Pretendard', sans-serif;
@ -4676,6 +4697,26 @@
}
}
.hero-manual-button {
width: 100%;
padding: 11px 16px;
height: 48px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-normal);
}
.hero-manual-button:hover {
border-color: rgba(255, 255, 255, 0.6);
color: #fff;
background: rgba(255, 255, 255, 0.06);
}
.hero-button:hover {
background-color: #9a5ef0;
animation: none;
@ -9265,6 +9306,15 @@
font-weight: 600;
}
.city-modal-item-all {
border-style: dashed;
border-color: rgba(255,255,255,0.3);
}
.city-modal-item-all.active {
border-style: solid;
}
.ado2-contents-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@ -12464,6 +12514,31 @@
border-color: #9BCACC;
}
.manual-modal-city-btn {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid rgba(155, 202, 204, 0.25);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.3);
font-size: 14px;
text-align: left;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: border-color 0.15s;
box-sizing: border-box;
}
.manual-modal-city-btn.selected {
color: #fff;
}
.manual-modal-city-btn:hover {
border-color: #9BCACC;
}
.manual-modal-actions {
display: flex;
gap: 8px;

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 660.69 145.69">
<!-- Generator: Adobe Illustrator 30.5.1, SVG Export Plug-In . SVG Version: 2.1.4 Build 3) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<g>
<g>
<path class="st0" d="M53.46,93.51l11.28-12.51h64.08l-31.22-63.81L32.69,93.51H15.09L90.17,5.91c2.48-2.89,6.19-5.64,10.73-5.64s7.29,2.48,8.8,5.64l43.45,87.6H53.46Z"/>
<path class="st0" d="M162.65,93.51l10.04-57.34h14.99l-7.84,44.83h42.22c23.38,0,43.18-16.78,43.18-38.5,0-16.64-12.51-28.33-31.35-28.33h-57.21l11.83-12.65h47.58c26.68,0,44.14,15.68,44.14,38.5,0,29.84-27.5,53.49-60.37,53.49h-57.21Z"/>
<path class="st0" d="M343.75,95.02c-36.17,0-54.45-12.1-54.45-38.37,0-38.09,23.24-56.65,71.23-56.65,36.17,0,54.46,11.96,54.46,38.23,0,38.09-23.1,56.79-71.23,56.79ZM358.33,12.51c-39.6,0-53.9,12.24-53.9,42.63,0,19.94,13.2,27.36,41.53,27.36,39.47,0,53.91-12.38,53.91-42.77,0-19.94-13.2-27.23-41.53-27.23Z"/>
<path class="st0" d="M417.47,93.51l4.81-27.23c2.89-16.5,15.68-24.48,36.58-24.48h40.15c16.91,0,23.79-5.78,23.79-15.68,0-9.08-9.08-11.96-26.4-11.96h-59.54l11.96-12.65h55.97c23.51,0,32.73,8.66,32.73,21.59,0,16.91-11,29.7-39.05,29.7h-40.15c-12.65,0-19.8,3.99-21.59,13.34l-2.47,14.85h94.75l-11.83,12.51h-99.7Z"/>
</g>
<g>
<path class="st0" d="M542.35,92.89c-2.38,0-4.15-1.38-4.15-3.61,0-3.07,2.69-4.84,5.68-4.84,2.46,0,4.3,1.31,4.3,3.53,0,3.07-2.69,4.92-5.84,4.92Z"/>
<path class="st0" d="M577.37,92.2l6.3-6.99h35.79l-17.43-35.63-36.25,42.62h-9.83l41.93-48.92c1.38-1.61,3.46-3.15,5.99-3.15s4.07,1.38,4.92,3.15l24.27,48.92h-55.68Z"/>
<path class="st0" d="M643.26,92.2l9.14-51.38h8.29l-9.14,51.38h-8.29Z"/>
</g>
</g>
<g>
<path class="st0" d="M.43,125.73c1.34-5.83,5.61-10.49,11.96-10.49,5.4,0,8.98,3.3,9.1,8.33,0,.17-.08.25-.23.25h-2.56c-.15,0-.23-.08-.23-.23-.17-3.74-2.48-5.9-6.18-5.9-4.69,0-7.56,3.67-8.65,8.31-1.31,5.71.33,11.09,6.32,11.09,3.43,0,6.17-1.81,7.52-6.23.03-.13.13-.2.27-.2h2.61c.17,0,.25.12.2.3-1.66,5.75-5.58,8.58-10.76,8.58-8.23,0-10.96-6.85-9.37-13.81Z"/>
<path class="st0" d="M25.26,129.28c1.02-4.34,4.26-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM38.31,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
<path class="st0" d="M44.55,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M66.61,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
<path class="st0" d="M78.34,129.22c.92-4.04,3.95-7.71,8.98-7.71s7.96,3.69,6.8,9.15c-.02.13-.12.2-.25.2h-12.78c-.37,3.18.57,6.5,4.51,6.5,2.35,0,4.21-1.12,5.09-3.94.05-.13.15-.2.28-.2h2.41c.17,0,.25.12.2.29-1.17,3.99-4.07,5.96-8.01,5.96-6.68,0-8.38-5.39-7.24-10.25ZM91.3,128.82c.47-3-1.14-5.21-4.15-5.21-3.17,0-4.98,2.43-5.71,5.21h9.87Z"/>
<path class="st0" d="M97.15,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M119.21,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
<path class="st0" d="M138.69,138.85l2.7-11.23,2.63-11.75c.03-.15.13-.22.27-.22h3.48c.15,0,.23.08.25.22l2.25,14.38.77,4.94,3.05-5.01,8.73-14.36c.07-.12.17-.17.3-.17h3.84c.15,0,.25.1.2.27l-2.76,11.69-2.53,11.28c-.03.15-.13.22-.27.22h-2.66c-.17,0-.25-.1-.22-.27l2.13-9.05,2.4-9.94-11.64,19.08c-.07.12-.17.17-.29.17h-2.11c-.15,0-.23-.08-.27-.22l-3.03-19.33-2.23,10.24-2.08,9.1c-.03.15-.13.22-.27.22h-2.41c-.17,0-.25-.1-.22-.27Z"/>
<path class="st0" d="M168.95,129.55c1.01-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM181.38,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
<path class="st0" d="M187.85,138.85l2.04-8.54,1.83-8.23c.03-.15.13-.22.27-.22h2.56c.15,0,.25.1.2.27l-.79,3.07c1.74-2.29,4.07-3.5,6.42-3.5.37,0,.72.03,1.02.12.15.03.2.13.17.28l-.44,1.88c-.03.15-.13.2-.28.18-.27-.07-.59-.08-.9-.08-2.76,0-5.55,1.93-6.89,5.38l-2.14,9.45c-.03.15-.13.22-.27.22h-2.58c-.17,0-.27-.1-.22-.27Z"/>
<path class="st0" d="M202.32,138.87l2.71-11.38,2.58-11.61c.03-.15.13-.22.27-.22h2.58c.17,0,.27.1.22.27l-3.08,12.9,7.81-6.85c.1-.07.18-.12.3-.12h3.32c.22,0,.28.15.12.3l-7.04,5.93,3.94,10.74c.05.18-.02.29-.2.29h-2.68c-.13,0-.22-.05-.27-.2l-.8-2.51-2.33-6.4-2.98,2.61-1.39,6.3c-.03.15-.13.22-.27.22h-2.58c-.17,0-.25-.1-.22-.27Z"/>
<path class="st0" d="M219.82,129.22c.92-4.04,3.95-7.71,8.98-7.71s7.96,3.69,6.8,9.15c-.02.13-.12.2-.25.2h-12.78c-.37,3.18.57,6.5,4.51,6.5,2.35,0,4.21-1.12,5.09-3.94.05-.13.15-.2.28-.2h2.41c.17,0,.25.12.2.29-1.17,3.99-4.07,5.96-8.01,5.96-6.68,0-8.38-5.39-7.24-10.25ZM232.79,128.82c.47-3-1.14-5.21-4.15-5.21-3.17,0-4.98,2.43-5.71,5.21h9.87Z"/>
<path class="st0" d="M240.64,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.48-4.99Z"/>
<path class="st0" d="M251.75,138.85l2.04-8.65,1.83-8.13c.03-.15.13-.22.27-.22h2.65c.17,0,.25.1.22.27l-1.94,8.06-1.94,8.71c-.02.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.22-.27ZM256.2,119.55l.65-2.73c.03-.15.13-.22.27-.22h2.61c.17,0,.25.1.22.27l-.64,2.75c-.03.13-.13.22-.27.22h-2.63c-.17,0-.25-.12-.22-.28Z"/>
<path class="st0" d="M260.47,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M279.79,140.28c0-.15.1-.25.25-.25h2.58c.15,0,.23.08.25.25.03,2.09,1.71,3.28,4.04,3.28,3.1,0,4.71-1.78,5.63-5.71l.28-1.26c-1.34,1.56-3.12,2.58-5.31,2.58-5.06,0-6.85-4.72-5.7-9.68.99-4.31,3.89-7.89,8.34-7.89,2.88,0,4.54,1.44,5.26,3.54l.65-3.05c.03-.15.13-.22.27-.22h2.58c.17,0,.25.1.22.27l-2.04,8.63-1.61,7.12c-1.21,5.26-4,7.81-8.73,7.81-4.04,0-6.97-2.14-6.97-5.41ZM294.28,130.83c.82-3.6.13-7.12-3.59-7.12-3.23,0-5.08,2.7-5.83,5.93-.87,3.77-.02,7.42,3.54,7.42,3.02,0,5.09-2.78,5.88-6.23Z"/>
<path class="st0" d="M307.42,138.82l13.57-22.99c.07-.12.15-.17.28-.17h3.69c.15,0,.23.07.25.23l2.85,22.95c.02.17-.08.27-.23.27h-2.56c-.15,0-.23-.08-.25-.23l-.79-6.85h-9.53l-3.95,6.92c-.07.12-.15.17-.29.17h-2.88c-.18,0-.25-.12-.15-.3ZM323.99,129.52l-1.31-11.61-6.62,11.61h7.92Z"/>
<path class="st0" d="M332.8,132.49l.74-3.25,1.61-7.15c.03-.15.13-.22.27-.22h2.63c.17,0,.25.1.2.27l-1.64,6.82-.72,3.3c-.75,3.3.49,4.94,2.78,4.94,2.63,0,4.49-2.43,5.63-5.19l.72-3.08,1.51-6.84c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-1.84,7.57-2.08,9.2c-.02.15-.12.22-.25.22h-2.51c-.15,0-.25-.1-.2-.27l.69-2.76c-1.24,1.86-3.43,3.43-6.01,3.43-3.38,0-5.71-2.48-4.66-7.04Z"/>
<path class="st0" d="M353.75,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
<path class="st0" d="M365.46,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM378.51,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
<path class="st0" d="M384.76,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.19-1.84,3.22-3.43,5.71-3.43,2.18,0,3.85,1.17,4.31,3.45,1.27-1.84,3.42-3.45,5.93-3.45,3.25,0,5.55,2.45,4.51,7.04l-.75,3.25-1.61,7.15c-.02.15-.12.22-.25.22h-2.63c-.17,0-.25-.1-.22-.27l1.64-6.82.74-3.3c.79-3.47-.49-4.94-2.46-4.94-2.24,0-3.84,2.04-4.98,4.52-.02.25-.05.55-.12.84l-.6,2.6-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.15,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.77-3.47-.49-4.94-2.48-4.94-2.41,0-4.09,2.41-5.23,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M415.88,129.55c1-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM428.31,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
<path class="st0" d="M437.29,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
<path class="st0" d="M448.39,138.85l2.04-8.65,1.83-8.13c.03-.15.13-.22.27-.22h2.65c.17,0,.25.1.22.27l-1.94,8.06-1.94,8.71c-.02.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.22-.27ZM452.85,119.55l.65-2.73c.03-.15.13-.22.27-.22h2.61c.17,0,.25.1.22.27l-.64,2.75c-.03.13-.13.22-.27.22h-2.63c-.17,0-.25-.12-.22-.28Z"/>
<path class="st0" d="M458.71,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM471.76,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
<path class="st0" d="M478,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.24-1.86,3.43-3.43,6.01-3.43,3.38,0,5.7,2.48,4.66,7.04l-.74,3.25-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.17,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.74-3.3-.5-4.94-2.8-4.94-2.63,0-4.49,2.43-5.63,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
<path class="st0" d="M506.33,138.85l2.81-11.76,2.5-11.21c.03-.15.13-.22.27-.22h7.04c5.68,0,8.44,3.43,7.37,8.13-.9,3.89-4.31,6.28-9.6,6.28h-5.23l-1.98,8.83c-.03.15-.13.22-.27.22h-2.7c-.17,0-.25-.1-.22-.27ZM516.95,127.74c3.6,0,5.7-1.62,6.3-4.22.7-3.08-.79-5.51-4.52-5.51h-4.41l-2.33,9.73h4.96Z"/>
<path class="st0" d="M527.7,138.85l2.78-11.61,2.53-11.36c.03-.15.13-.22.27-.22h2.56c.17,0,.25.1.22.27l-2.7,11.28-2.61,11.69c-.03.15-.13.22-.27.22h-2.56c-.17,0-.27-.1-.22-.27Z"/>
<path class="st0" d="M537.92,129.55c1-4.36,3.89-7.96,8.36-7.96,2.85,0,4.49,1.47,5.21,3.55l.67-3.07c.03-.15.13-.22.27-.22h2.6c.17,0,.25.1.22.27l-1.96,8.23-1.91,8.54c-.03.15-.13.22-.27.22h-2.51c-.15,0-.25-.1-.2-.27l.54-2.13c-1.36,1.59-3.15,2.65-5.34,2.65-4.99,0-6.87-4.66-5.66-9.82ZM550.35,130.98c.85-3.72.13-7.27-3.57-7.27-3.2,0-5.04,2.71-5.81,5.95-.92,4,.03,7.61,3.52,7.61,3,0,5.04-2.78,5.86-6.28Z"/>
<path class="st0" d="M559.32,134.4l2.4-10.29h-1.96c-.17,0-.25-.1-.22-.29l.4-1.74c.03-.15.13-.22.27-.22h2.01l.89-4.1c.03-.15.13-.22.27-.22h2.66c.17,0,.25.1.22.27l-.99,4.05h3.97c.17,0,.25.1.22.27l-.42,1.76c-.03.13-.13.22-.27.22h-4.04l-2.35,10.22c-.45,1.98.08,2.78,1.56,2.78.59,0,1.26-.08,1.78-.2.18-.05.28.05.23.24l-.37,1.62c-.03.13-.1.2-.2.23-.75.22-1.71.38-2.58.38-2.35,0-4.37-1.26-3.49-4.99Z"/>
<path class="st0" d="M572.12,138.85l1.83-7.52,1.71-7.22h-2.45c-.17,0-.25-.1-.22-.29l.42-1.74c.03-.15.13-.22.27-.22h2.5l.37-1.56c.85-3.77,3.32-5.09,5.71-5.09.8,0,1.64.12,2.23.25.15.05.22.15.18.3l-.4,1.69c-.03.17-.13.22-.3.17-.45-.12-.97-.17-1.57-.17-1.36,0-2.4.85-2.9,3.08l-.32,1.32h3.79c.17,0,.25.1.22.27l-.4,1.76c-.03.13-.13.22-.27.22h-3.85l-1.66,7.22-1.64,7.57c-.03.15-.13.22-.27.22h-2.75c-.17,0-.25-.1-.22-.27Z"/>
<path class="st0" d="M584.65,129.28c1.02-4.34,4.25-7.77,9.1-7.77,5.75,0,8.31,4.74,7.04,10.19-1.02,4.34-4.24,7.77-9.08,7.77-5.76,0-8.33-4.74-7.05-10.19ZM597.7,131.48c.97-4.12-.25-7.86-4.07-7.86-3.17,0-5.13,2.61-5.9,5.88-.97,4.12.25,7.86,4.07,7.86,3.17,0,5.13-2.61,5.9-5.88Z"/>
<path class="st0" d="M603.95,138.85l2.04-8.54,1.83-8.23c.03-.15.13-.22.27-.22h2.56c.15,0,.25.1.2.27l-.79,3.07c1.74-2.29,4.07-3.5,6.42-3.5.37,0,.72.03,1.02.12.15.03.2.13.17.28l-.44,1.88c-.03.15-.13.2-.29.18-.27-.07-.59-.08-.9-.08-2.76,0-5.55,1.93-6.89,5.38l-2.14,9.45c-.03.15-.13.22-.27.22h-2.58c-.17,0-.27-.1-.22-.27Z"/>
<path class="st0" d="M618.42,138.85l1.83-7.57,2.08-9.2c.03-.15.13-.22.27-.22h2.5c.17,0,.25.1.22.27l-.7,2.76c1.19-1.84,3.22-3.43,5.71-3.43,2.18,0,3.85,1.17,4.31,3.45,1.27-1.84,3.42-3.45,5.93-3.45,3.25,0,5.55,2.45,4.51,7.04l-.75,3.25-1.61,7.15c-.02.15-.12.22-.25.22h-2.63c-.17,0-.25-.1-.22-.27l1.64-6.82.74-3.3c.79-3.47-.49-4.94-2.46-4.94-2.25,0-3.84,2.04-4.98,4.52-.02.25-.05.55-.12.84l-.6,2.6-1.61,7.15c-.03.15-.13.22-.27.22h-2.63c-.15,0-.25-.1-.2-.27l1.64-6.82.74-3.3c.77-3.47-.49-4.94-2.48-4.94-2.41,0-4.09,2.41-5.23,5.19l-.72,3.08-1.51,6.84c-.03.15-.13.22-.27.22h-2.66c-.15,0-.25-.1-.2-.27Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -14,8 +14,7 @@ import SocialConnectError from './pages/Social/SocialConnectError';
import YouTubeOAuthCallback from './pages/Social/YouTubeOAuthCallback';
import ADO2ContentsPage from './pages/Dashboard/ADO2ContentsPage';
import VideoDetailPage from './components/VideoDetailPage';
import LoginPromptModal from './components/LoginPromptModal';
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, AutocompleteRequest } from './utils/api';
import { crawlUrl, autocomplete, marketingAnalysis, kakaoCallback, isLoggedIn, saveTokens, getVideosList, AutocompleteRequest } from './utils/api';
import { saveSearchHistory } from './components/SearchHistory/useSearchHistory';
import { CrawlingResponse } from './types/api';
@ -101,8 +100,26 @@ const App: React.FC = () => {
);
const [error, setError] = useState<string | null>(null);
const [isAnalysisComplete, setIsAnalysisComplete] = useState(false);
const [afterLoadTarget, setAfterLoadTarget] = useState<ViewMode>('analysis');
const [scrollProgress, setScrollProgress] = useState(0);
const [isProcessingCallback, setIsProcessingCallback] = useState(false);
const tutorialVideoCheckedRef = useRef(false);
// generation_flow 진입 시 영상 보유 여부 확인 → 있으면 튜토리얼 자동 off
useEffect(() => {
if (viewMode !== 'generation_flow') return;
if (tutorialVideoCheckedRef.current) return;
tutorialVideoCheckedRef.current = true;
const ENABLED_KEY = 'ado2_tutorial_enabled';
if (localStorage.getItem(ENABLED_KEY) !== null) return;
getVideosList(1, 1).then(response => {
const hasVideos = response.items.some(v => v.result_movie_url?.trim());
if (hasVideos) {
localStorage.setItem(ENABLED_KEY, 'false');
window.dispatchEvent(new Event('ado2-tutorial-auto-disable'));
}
}).catch(() => {});
}, [viewMode]);
// 카카오 로그인 콜백 처리 (URL에서 토큰 또는 code 파라미터 확인)
useEffect(() => {
@ -266,6 +283,7 @@ const App: React.FC = () => {
const handleStartAnalysis = async (url: string) => {
if (!url.trim()) return;
setAfterLoadTarget('analysis');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
@ -291,6 +309,7 @@ const App: React.FC = () => {
// 업체명 자동완성으로 분석 시작
const handleAutocomplete = async (request: AutocompleteRequest) => {
setAfterLoadTarget('analysis');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
@ -316,6 +335,7 @@ const App: React.FC = () => {
// 업체명·주소 수동 입력으로 마케팅 분석 API 호출
const handleManualInput = async (businessName: string, address: string) => {
setAfterLoadTarget('generation_flow');
setViewMode('loading');
setIsAnalysisComplete(false);
setError(null);
@ -442,7 +462,7 @@ const App: React.FC = () => {
return (
<LoadingSection
isComplete={isAnalysisComplete}
onComplete={() => setViewMode('analysis')}
onComplete={() => setViewMode(afterLoadTarget)}
/>
);
}

View File

@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import CitySelectModal, { REGIONS } from './CitySelectModal';
interface BusinessNameInputModalProps {
onClose: () => void;
@ -9,7 +11,9 @@ interface BusinessNameInputModalProps {
const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose, onSubmit }) => {
const { t } = useTranslation();
const [businessName, setBusinessName] = useState('');
const [address, setAddress] = useState('');
const [selectedCity, setSelectedCity] = useState('');
const [detailAddress, setDetailAddress] = useState('');
const [isCityModalOpen, setIsCityModalOpen] = useState(false);
useEffect(() => {
document.body.style.overflow = 'hidden';
@ -25,11 +29,19 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
};
}, [onClose]);
const isValid = businessName.trim().length > 0 && address.trim().length > 0;
const handleCitySelect = (city: string) => {
const region = REGIONS.find(r => r.cities.includes(city));
// 특별시/광역시는 도 이름 없이 도시명만 사용
const prefix = region && region.label !== '특별시 / 광역시' ? `${region.label} ` : '';
setSelectedCity(`${prefix}${city}`);
};
const isValid = businessName.trim().length > 0 && selectedCity.length > 0 && detailAddress.trim().length > 0;
const handleSubmit = () => {
if (!isValid) return;
onSubmit(businessName.trim(), address.trim());
const fullAddress = `${selectedCity} ${detailAddress.trim()}`;
onSubmit(businessName.trim(), fullAddress);
onClose();
};
@ -37,7 +49,11 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
if (e.key === 'Enter' && isValid) handleSubmit();
};
return (
// CitySelectModal의 selected prop용 — 도시명만 추출
const cityOnly = selectedCity.split(' ').pop() ?? '';
return createPortal(
<>
<div className="manual-modal-backdrop" onClick={onClose}>
<div className="manual-modal" onClick={e => e.stopPropagation()}>
<div className="manual-modal-header">
@ -61,13 +77,27 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelAddress')}</label>
<label className="manual-modal-label">{t('landing.hero.manualLabelRegion')}</label>
<button
type="button"
className={`manual-modal-city-btn ${selectedCity ? 'selected' : ''}`}
onClick={() => setIsCityModalOpen(true)}
>
<span>{selectedCity || t('landing.hero.manualPlaceholderRegion')}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
</div>
<div className="manual-modal-field">
<label className="manual-modal-label">{t('landing.hero.manualLabelDetail')}</label>
<input
type="text"
className="manual-modal-input"
placeholder={t('landing.hero.manualPlaceholderAddress')}
value={address}
onChange={e => setAddress(e.target.value)}
placeholder={t('landing.hero.manualPlaceholderDetail')}
value={detailAddress}
onChange={e => setDetailAddress(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={100}
/>
@ -89,6 +119,16 @@ const BusinessNameInputModal: React.FC<BusinessNameInputModalProps> = ({ onClose
</div>
</div>
</div>
{isCityModalOpen && (
<CitySelectModal
selected={cityOnly}
onSelect={(city) => { handleCitySelect(city); setIsCityModalOpen(false); }}
onClose={() => setIsCityModalOpen(false)}
/>
)}
</>,
document.body
);
};

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
const REGIONS: { label: string; cities: string[] }[] = [
export const REGIONS: { label: string; cities: string[] }[] = [
{
label: '특별시 / 광역시',
cities: ['서울시', '부산시', '대구시', '인천시', '광주시', '대전시', '울산시', '세종시'],
@ -88,10 +88,11 @@ const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, o
return () => { document.body.style.overflow = ''; };
}, []);
const cities = REGIONS.find(r => r.label === activeRegion)?.cities ?? [];
const activeEntry = REGIONS.find(r => r.label === activeRegion);
const cities = activeEntry?.cities ?? [];
const handleCityClick = (city: string) => {
onSelect(city === selected ? '' : city);
const handleSelect = (value: string) => {
onSelect(value === selected ? '' : value);
onClose();
};
@ -112,25 +113,38 @@ const CitySelectModal: React.FC<CitySelectModalProps> = ({ selected, onSelect, o
{activeRegion === null ? (
<div className="city-modal-grid">
{REGIONS.map(r => (
{REGIONS.map(r => {
const isRegionSelected = selected === r.label;
const hasCitySelected = r.cities.includes(selected);
return (
<button
key={r.label}
className={`city-modal-region-item ${r.cities.includes(selected) ? 'has-selected' : ''}`}
className={`city-modal-region-item ${isRegionSelected || hasCitySelected ? 'has-selected' : ''}`}
onClick={() => setActiveRegion(r.label)}
>
<span>{r.label}</span>
{r.cities.includes(selected) && <span className="city-modal-region-badge">{selected}</span>}
{isRegionSelected && <span className="city-modal-region-badge"></span>}
{hasCitySelected && <span className="city-modal-region-badge">{selected}</span>}
<span className="city-modal-arrow"></span>
</button>
))}
);
})}
</div>
) : (
<div className="city-modal-item-wrap">
{activeRegion !== '특별시 / 광역시' && (
<button
className={`city-modal-item city-modal-item-all ${selected === activeRegion ? 'active' : ''}`}
onClick={() => handleSelect(activeRegion!)}
>
</button>
)}
{cities.map(city => (
<button
key={city}
className={`city-modal-item ${selected === city ? 'active' : ''}`}
onClick={() => handleCityClick(city)}
onClick={() => handleSelect(city)}
>
{city}
</button>

View File

@ -0,0 +1,342 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../utils/api';
import { useSearchHistory } from './SearchHistory/useSearchHistory';
import SearchHistoryDropdown from './SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from './BusinessNameInputModal';
type SearchType = 'url' | 'name' | 'manual';
export type { SearchType };
const isValidUrl = (string: string): boolean => {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
interface SearchInputFormProps {
onAnalyze?: (value: string, type: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
onManualButtonClick?: () => void;
error?: string | null;
}
const SearchInputForm: React.FC<SearchInputFormProps> = ({
onAnalyze,
onAutocomplete,
onManualInput,
onManualButtonClick,
error: externalError,
}) => {
const { t } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState('');
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
];
const getPlaceholder = () =>
searchType === 'url' ? 'https://naver.me/abcdef' : t('landing.hero.placeholderBusinessName');
const getGuideText = () =>
searchType === 'url' ? t('landing.hero.guideUrl') : t('landing.hero.guideBusinessName');
const handleAutocompleteSearch = useCallback(async (query: string) => {
if (!query.trim() || searchType !== 'name') {
setAutocompleteResults([]);
setShowAutocomplete(false);
return;
}
setIsAutocompleteLoading(true);
try {
const response = await searchAccommodation(query);
setAutocompleteResults(response.items || []);
setShowAutocomplete(response.items && response.items.length > 0);
} catch {
setAutocompleteResults([]);
setShowAutocomplete(false);
} finally {
setIsAutocompleteLoading(false);
}
}, [searchType]);
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
setInputValue(item.title.replace(/<[^>]*>/g, ''));
setSelectedItem(item);
setShowAutocomplete(false);
setAutocompleteResults([]);
setHighlightedIndex(-1);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (showAutocomplete && autocompleteResults.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev => prev < autocompleteResults.length - 1 ? prev + 1 : 0);
return;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev => prev > 0 ? prev - 1 : autocompleteResults.length - 1);
return;
case 'Enter':
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
}
return;
case 'Escape':
e.preventDefault();
setShowAutocomplete(false);
setHighlightedIndex(-1);
return;
}
}
if (e.key === 'Enter') {
e.preventDefault();
}
};
const handleStart = () => {
if (!inputValue.trim()) {
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
return;
}
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
setLocalError(t('landing.hero.errorInvalidUrl'));
return;
}
setLocalError('');
if (searchType === 'name' && selectedItem && onAutocomplete) {
onAutocomplete({
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
});
} else if (onAnalyze) {
onAnalyze(inputValue, searchType);
}
};
const handleManualButtonClick = () => {
if (onManualButtonClick) {
onManualButtonClick();
} else {
setIsManualModalOpen(true);
}
};
const error = externalError || localError;
return (
<div className="hero-form">
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
{/* 드롭다운 */}
<div className="hero-dropdown-container" ref={dropdownRef}>
<button
type="button"
className="hero-dropdown-trigger"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isDropdownOpen && (
<div className="hero-dropdown-menu">
{searchTypeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
setSearchType(option.value);
setIsDropdownOpen(false);
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
{/* 입력 필드 */}
<div className="hero-input-container" ref={autocompleteRef}>
<input
type="text"
value={inputValue}
onChange={(e) => {
let value = e.target.value;
if (searchType === 'url') {
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch && urlMatch[0] !== value) value = urlMatch[0];
}
setInputValue(value);
setSelectedItem(null);
setHighlightedIndex(-1);
hideOnInput(value);
if (localError) setLocalError('');
if (searchType === 'name') {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => handleAutocompleteSearch(value), 300);
}
}}
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchType !== 'url') return;
const pasted = e.clipboardData.getData('text');
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
e.preventDefault();
setInputValue(urlMatch[0]);
if (localError) setLocalError('');
}
}}
onFocus={() => {
setIsFocused(true);
if (searchType === 'name' && autocompleteResults.length > 0) setShowAutocomplete(true);
openHistory(inputValue);
}}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
onKeyDown={handleKeyDown}
placeholder={getPlaceholder()}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
/>
{/* 최근 검색 히스토리 */}
{showHistory && !showAutocomplete && (
<SearchHistoryDropdown
items={filteredHistory}
onSelect={(item) => {
closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value);
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
}
}}
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
className="hero-autocomplete-dropdown"
itemClassName="hero-autocomplete-item"
titleClassName="hero-autocomplete-title"
addressClassName="hero-autocomplete-address"
/>
)}
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
<div className="hero-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
) : (
autocompleteResults.map((item, index) => (
<button
key={index}
type="button"
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
onMouseDown={(e) => { e.preventDefault(); handleSelectAutocomplete(item); }}
onMouseEnter={() => setHighlightedIndex(index)}
>
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
</button>
))
)}
</div>
)}
</div>
{/* 입력 clear 버튼 */}
{inputValue && (
<button
type="button"
className="hero-input-clear"
onClick={() => {
setInputValue('');
setSelectedItem(null);
setLocalError('');
setAutocompleteResults([]);
setShowAutocomplete(false);
}}
>
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
</button>
)}
</div>
<span className="hero-input-hint">{getGuideText()}</span>
{error && <p className="hero-error">{error}</p>}
<button onClick={handleStart} className="hero-button">
{t('landing.hero.analyzeButton')}
</button>
<button type="button" className="hero-manual-button" onClick={handleManualButtonClick}>
{t('landing.hero.searchTypeManual')}
</button>
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
{!onManualButtonClick && isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => {
setIsManualModalOpen(false);
onManualInput?.(businessName, address);
}}
/>
)}
</div>
);
};
export default SearchInputForm;

View File

@ -143,7 +143,11 @@ const TutorialOverlay: React.FC<TutorialOverlayProps> = ({
};
const tryBind = (): boolean => {
const el = document.querySelector(hint.targetSelector) as HTMLElement | null;
const els = Array.from(document.querySelectorAll(hint.targetSelector));
const el = (els.find(e => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
}) ?? els[0]) as HTMLElement | null;
if (!el) return false;
bindToTarget(el);
return true;

View File

@ -64,6 +64,15 @@ export const tutorialSteps: TutorialStepDef[] = [
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-manual-button',
titleKey: 'tutorial.landing.manual.title',
descriptionKey: 'tutorial.landing.manual.desc',
position: 'bottom',
clickToAdvance: false,
noSpotlight: true,
variant: 'bubble',
},
{
targetSelector: '.hero-button',
titleKey: 'tutorial.landing.button.title',

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import { tutorialSteps, TutorialHint, TUTORIAL_PAGE_GROUPS } from './tutorialSteps';
// 현재 키가 속한 그룹의 전체 힌트 수와 현재까지의 offset 반환
@ -189,6 +189,16 @@ export function useTutorial(): UseTutorialReturn {
return getSeenKeys().includes(key);
}, []);
// 영상 보유 사용자 자동 off 이벤트 수신
useEffect(() => {
const handler = () => {
setIsActive(false);
setIsEnabled(false);
};
window.addEventListener('ado2-tutorial-auto-disable', handler);
return () => window.removeEventListener('ado2-tutorial-auto-disable', handler);
}, []);
return {
isActive,
isEnabled,

View File

@ -30,6 +30,7 @@
"intro": { "title": "Welcome to ADO2 Tutorial", "desc": "We'll guide you through ADO2 step by step." },
"dropdown": { "title": "Choose Search Type", "desc": "Select URL or business name from the dropdown." },
"field": { "title": "Enter Search Term", "desc": "For URL, paste a Naver Maps share URL.\nFor business name, type the name and select from the list." },
"manual": { "title": "Direct Input", "desc": "You can also enter the business name and address manually to start analysis." },
"button": { "title": "Start Brand Analysis", "desc": "Click the button to let AI start analyzing your brand." }
},
"asset": {
@ -174,8 +175,12 @@
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address"
"manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region",
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
},
"welcome": {
"title": "Welcome to ADO2.AI",
@ -208,11 +213,19 @@
"testDataLoading": "Loading...",
"testData": "Test Data",
"searchTypeManual": "Manual Input",
"errorSelectFromList": "Please select a business from the list.",
"errorUrlRequired": "Please enter a URL.",
"errorNameRequired": "Please enter a business name.",
"errorInvalidUrl": "Invalid URL format. (e.g. https://example.com)",
"manualModalTitle": "Enter Business Info",
"manualLabelName": "Business Name",
"manualLabelAddress": "Address",
"manualLabelRegion": "Region",
"manualLabelDetail": "Detail Address",
"manualPlaceholderName": "Enter the business name",
"manualPlaceholderAddress": "Enter the address"
"manualPlaceholderAddress": "Enter the address",
"manualPlaceholderRegion": "Select a region",
"manualPlaceholderDetail": "Enter detail address (e.g. Gangnam-gu Teheran-ro 123)"
},
"assetManagement": {
"title": "Brand Assets",
@ -434,7 +447,7 @@
"title": "My Info",
"tabBasic": "Basic Info",
"tabPayment": "Payment Info",
"tabBusiness": "My Business & Social Channel Management",
"tabBusiness": "Social Channel Management",
"basicPlaceholder": "Basic information settings are coming soon.",
"paymentPlaceholder": "Payment information settings are coming soon.",
"myBusiness": "My Business",

View File

@ -30,6 +30,7 @@
"intro": { "title": "ADO2 튜토리얼 시작", "desc": "ADO2 사용 방법을 단계별로 안내해 드릴게요." },
"dropdown": { "title": "검색 방식 선택", "desc": "드롭다운에서 URL 또는 업체명 중 원하는 방식을 선택하세요." },
"field": { "title": "입력하기", "desc": "URL 방식이라면 네이버 지도 공유 URL,\n업체명 방식이라면 업체명을 입력하고 목록에서 선택하세요." },
"manual": { "title": "직접 입력", "desc": "업체명과 주소를 직접 입력해서 분석을 시작할 수도 있어요." },
"button": { "title": "브랜드 분석 시작", "desc": "버튼을 누르면 AI가 브랜드를 분석하기 시작해요." }
},
"asset": {
@ -55,7 +56,7 @@
},
"myInfo": {
"myInfo": { "title": "내 정보", "desc": "내 정보에서는 소셜 연결과 연결된 계정을 확인 할 수 있어요." },
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instargram 연결은 오픈 예정입니다." },
"connect": { "title": "연결하기", "desc": "YouTube 연결 버튼을 누르면 연결 페이지로 이동합니다.", "note": "Instagram 연결은 오픈 예정입니다." },
"connected": { "title": "연결 계정", "desc": "연결된 소셜 계정 목록이에요. \n연결 후 여기서 확인할 수 있어요." },
"ado2": { "title": "ADO2 콘텐츠", "desc": "이제 생성된 영상을 업로드할 수 있어요. \n클릭해서 이동하세요." }
},
@ -174,8 +175,12 @@
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요."
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
},
"welcome": {
"title": "ADO2.AI에 오신 것을 환영합니다.",
@ -208,11 +213,19 @@
"testDataLoading": "로딩 중...",
"testData": "테스트 데이터",
"searchTypeManual": "직접 입력",
"errorSelectFromList": "목록에서 업체를 선택해 주세요.",
"errorUrlRequired": "URL을 입력해주세요.",
"errorNameRequired": "업체명을 입력해주세요.",
"errorInvalidUrl": "올바른 URL 형식이 아닙니다. (예: https://example.com)",
"manualModalTitle": "업체 정보 입력",
"manualLabelName": "업체명",
"manualLabelAddress": "주소",
"manualLabelRegion": "지역",
"manualLabelDetail": "상세 주소",
"manualPlaceholderName": "업체명을 입력하세요.",
"manualPlaceholderAddress": "주소를 입력하세요."
"manualPlaceholderAddress": "주소를 입력하세요.",
"manualPlaceholderRegion": "지역을 선택하세요.",
"manualPlaceholderDetail": "상세 주소를 입력하세요. (예: 강남구 테헤란로 123)"
},
"assetManagement": {
"title": "브랜드 에셋",
@ -434,7 +447,7 @@
"title": "내 정보",
"tabBasic": "기본 정보",
"tabPayment": "결제 정보",
"tabBusiness": "내 비즈니스 & 소셜 채널 관리",
"tabBusiness": "소셜 채널 관리",
"basicPlaceholder": "기본 정보 설정 기능이 준비 중입니다.",
"paymentPlaceholder": "결제 정보 설정 기능이 준비 중입니다.",
"myBusiness": "내 비즈니스",

View File

@ -50,7 +50,7 @@ const LoadingSection: React.FC<LoadingSectionProps> = ({ onComplete, isComplete
<div className="loading-content">
{/* Logo */}
<div className="loading-logo">
<img src="/assets/images/ado2-loading-logo.svg" alt="ADO2" />
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
</div>
{/* Loading Spinner and Text */}

View File

@ -113,7 +113,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
const hasConnectedAccounts = youtubeAccounts.length > 0 || instagramAccounts.length > 0;
const tabs = [
{ id: 'basic' as TabType, label: t('myInfo.tabBasic') },
// { id: 'basic' as TabType, label: t('myInfo.tabBasic') },
{ id: 'payment' as TabType, label: t('myInfo.tabPayment') },
{ id: 'business' as TabType, label: t('myInfo.tabBusiness') },
];
@ -205,11 +205,11 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
{/* 탭 컨텐츠 */}
<div className="myinfo-content">
{activeTab === 'basic' && (
{/* {activeTab === 'basic' && (
<div className="myinfo-section">
<p className="myinfo-placeholder">{t('myInfo.basicPlaceholder')}</p>
</div>
)}
)} */}
{activeTab === 'payment' && (
<div className="myinfo-section">
@ -240,7 +240,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
{activeTab === 'business' && (
<>
{/* 내 비즈니스 섹션 */}
{/*
<div className="myinfo-section">
<h2 className="myinfo-section-title">{t('myInfo.myBusiness')}</h2>
<div className="myinfo-business-card">
@ -261,7 +261,7 @@ const MyInfoContent: React.FC<MyInfoContentProps> = ({ initialTab }) => {
</button>
</div>
</div>
</div>
</div> */}
{/* 소셜 채널 섹션 */}
<div className="myinfo-section">

View File

@ -26,6 +26,19 @@ type GenerationStatus = 'idle' | 'generating_lyric' | 'generating_song' | 'polli
const MAX_RETRY_COUNT = 3;
const sanitizeError = (message: string, fallback: string): string => {
if (
message.includes('Traceback') ||
message.includes('File "/') ||
message.includes('httpx.') ||
message.includes('httpcore.') ||
message.length > 200
) {
return fallback;
}
return message;
};
const LANGUAGE_FLAGS: Record<string, string> = {
'한국어': '🇰🇷',
'English': '🇺🇸',
@ -131,7 +144,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
}
} else {
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
setErrorMessage(sanitizeError(
error instanceof Error ? error.message : '',
t('soundStudio.musicGenerationError')
));
setRetryCount(0);
}
}
@ -168,7 +184,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} catch (error) {
console.error('Song regeneration failed:', error);
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.songRegenerationError'));
setErrorMessage(sanitizeError(
error instanceof Error ? error.message : '',
t('soundStudio.songRegenerationError')
));
setRetryCount(0);
}
};
@ -362,7 +381,10 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
} catch (error) {
console.error('Music generation failed:', error);
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : t('soundStudio.musicGenerationError'));
setErrorMessage(sanitizeError(
error instanceof Error ? error.message : '',
t('soundStudio.musicGenerationError')
));
setRetryCount(0);
}
};
@ -496,8 +518,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{['한국어', 'English', '中文'].map((lang) => (
<button
key={lang}
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
disabled={isGenerating || selectedType === '배경음악'}
onClick={() => !isGenerating && setSelectedLang(lang)}
disabled={isGenerating}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
>
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}
@ -508,8 +530,8 @@ const SoundStudioContent: React.FC<SoundStudioContentProps> = ({
{['日本語', 'ไทย', 'Tiếng Việt'].map((lang) => (
<button
key={lang}
onClick={() => !isGenerating && selectedType !== '배경음악' && setSelectedLang(lang)}
disabled={isGenerating || selectedType === '배경음악'}
onClick={() => !isGenerating && setSelectedLang(lang)}
disabled={isGenerating}
className={`genre-btn ${selectedLang === lang ? 'active' : ''}`}
>
<span>{LANGUAGE_FLAGS[lang]}</span> {lang}

View File

@ -1,12 +1,8 @@
import React, { useState, useRef, useCallback } from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
import { AutocompleteRequest } from '../../utils/api';
import { CrawlingResponse } from '../../types/api';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
type SearchType = 'url' | 'name' | 'manual';
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
// 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
@ -21,34 +17,8 @@ interface UrlInputContentProps {
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [isLoadingTest, setIsLoadingTest] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const handleSelectHistory = (item: { type: 'url' | 'name'; value: string; address?: string; roadAddress?: string }) => {
closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value);
setSelectedItem({
title: item.value,
address: item.address || '',
roadAddress: item.roadAddress || '',
});
}
};
// 테스트 데이터 로드 핸들러
const handleTestData = async () => {
if (!onTestData) return;
setIsLoadingTest(true);
@ -63,269 +33,21 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
setIsLoadingTest(false);
}
};
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('urlInput.searchTypeBusinessName') },
// { value: 'manual' as SearchType, label: t('urlInput.searchTypeManual') },
];
const getPlaceholder = () => {
return searchType === 'url'
? 'https://naver.me/abcdef'
: t('urlInput.placeholderBusinessName');
};
const getGuideText = () => {
return searchType === 'url'
? t('urlInput.guideUrl')
: t('urlInput.guideBusinessName');
};
// 업체명 검색 시 자동완성 (디바운스 적용)
const handleAutocompleteSearch = useCallback(async (query: string) => {
if (!query.trim() || searchType !== 'name') {
setAutocompleteResults([]);
setShowAutocomplete(false);
return;
}
setIsAutocompleteLoading(true);
try {
const response = await searchAccommodation(query);
setAutocompleteResults(response.items || []);
setShowAutocomplete(response.items && response.items.length > 0);
} catch (error) {
console.error('자동완성 검색 오류:', error);
setAutocompleteResults([]);
setShowAutocomplete(false);
} finally {
setIsAutocompleteLoading(false);
}
}, [searchType]);
// 자동완성 항목 선택 - 업체 정보 저장
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
setSelectedItem(item); // 선택된 업체 정보 저장
setShowAutocomplete(false);
setAutocompleteResults([]);
setHighlightedIndex(-1);
};
// 키보드 네비게이션 핸들러
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
return;
}
if (!showAutocomplete || autocompleteResults.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev =>
prev < autocompleteResults.length - 1 ? prev + 1 : 0
);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : autocompleteResults.length - 1
);
break;
case 'Escape':
setShowAutocomplete(false);
setHighlightedIndex(-1);
break;
}
};
// 붙여넣기 시 URL만 추출
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchType !== 'url') return;
const pasted = e.clipboardData.getData('text');
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
e.preventDefault();
setInputValue(urlMatch[0]);
}
};
// 폼 제출 처리
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
};
const handleAnalyzeClick = () => {
if (!inputValue.trim()) return;
if (searchType === 'name') {
if (selectedItem && onAutocomplete) {
const request: AutocompleteRequest = {
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
};
onAutocomplete(request);
}
// selectedItem 없으면 아무것도 하지 않음 (드롭다운에서 선택 필요)
} else {
// URL 검색인 경우 기존 로직
onAnalyze(inputValue.trim(), searchType);
}
};
return (
<div className="url-input-container">
<div className="url-input-content">
{/* 로고 */}
<div className="url-input-logo">
<img src="/assets/images/ado2-logo.svg" alt="ADO2" />
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
</div>
{/* URL 입력 폼 */}
<form onSubmit={handleSubmit} className="url-input-form">
<div className="url-input-wrapper">
{/* 드롭다운 */}
<div className="url-input-dropdown-container">
<button
type="button"
className="url-input-dropdown-trigger"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`url-input-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isDropdownOpen && (
<div className="url-input-dropdown-menu">
{searchTypeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`url-input-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
if (option.value === 'manual') {
setIsManualModalOpen(true);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
}
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
{/* 입력 필드 */}
<div className="url-input-field-container" ref={autocompleteRef}>
<input
type="text"
value={inputValue}
onChange={(e) => {
let value = e.target.value;
// URL 모드일 때 URL만 추출 (예: "[네이버 지도] https://...")
if (searchType === 'url') {
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch && urlMatch[0] !== value) {
value = urlMatch[0];
}
}
setInputValue(value);
hideOnInput(value);
// 업체명 검색일 때 자동완성 검색 (디바운스)
if (searchType === 'name') {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleAutocompleteSearch(value);
}, 300);
}
}}
onPaste={handlePaste}
onFocus={() => {
if (searchType === 'name' && autocompleteResults.length > 0) {
setShowAutocomplete(true);
}
openHistory(inputValue);
}}
onBlur={() => setTimeout(() => closeHistory(), 150)}
placeholder={getPlaceholder()}
className="url-input-field"
<SearchInputForm
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
error={error}
/>
{/* 최근 검색 히스토리 */}
{showHistory && !showAutocomplete && (
<SearchHistoryDropdown
items={filteredHistory}
onSelect={handleSelectHistory}
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
/>
)}
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
<div className="url-input-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="url-input-autocomplete-loading">{t('urlInput.searching')}</div>
) : (
autocompleteResults.map((item, index) => (
<button
key={index}
type="button"
className="url-input-autocomplete-item"
onMouseDown={(e) => {
e.preventDefault();
handleSelectAutocomplete(item);
}}
>
<div className="url-input-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
<div className="url-input-autocomplete-address">{item.roadAddress || item.address}</div>
</button>
))
)}
</div>
)}
</div>
</div>
{/* 안내 텍스트 */}
<p className="url-input-guide">
{getGuideText()}
</p>
{/* 에러 메시지 */}
{error && (
<p className="url-input-error">{error}</p>
)}
{/* 검색 버튼 */}
<button type="button" onClick={handleAnalyzeClick} className="url-input-button">
{t('landing.hero.analyzeButton')}
</button>
</form>
</div>
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
@ -338,13 +60,6 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
</button>
)}
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
/>
)}
</div>
);
};

View File

@ -1,39 +1,19 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { searchAccommodation, AccommodationSearchItem, AutocompleteRequest } from '../../utils/api';
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
import { CrawlingResponse } from '../../types/api';
import { useTutorial } from '../../components/Tutorial/useTutorial';
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
import { useSearchHistory } from '../../components/SearchHistory/useSearchHistory';
import SearchHistoryDropdown from '../../components/SearchHistory/SearchHistoryDropdown';
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
type SearchType = 'url' | 'name' | 'manual';
import LoginPromptModal from '../../components/LoginPromptModal';
import SearchInputForm from '../../components/SearchInputForm';
import { SearchType } from '../../components/SearchInputForm';
// 환경변수에서 테스트 모드 확인
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
onTestData?: (data: CrawlingResponse) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number; // 0 ~ 1 (스크롤 진행률)
}
const isValidUrl = (string: string): boolean => {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
// Orb configuration with movement zones to prevent overlap
interface OrbConfig {
id: string;
@ -41,66 +21,39 @@ interface OrbConfig {
initialX: number;
initialY: number;
color: string;
// Movement bounds for each orb
minX: number;
maxX: number;
minY: number;
maxY: number;
}
// 6 orbs distributed in a 3x2 grid pattern with overlapping zones for smooth movement
const orbConfigs: OrbConfig[] = [
// Top-left zone
{ id: 'orb-1', size: 500, initialX: -10, initialY: -10, color: 'radial-gradient(circle, #C490FF 20%, #AE72F9 50%, rgba(94, 235, 195, 0.4) 100%)', minX: -30, maxX: 35, minY: -30, maxY: 40 },
// Top-right zone
{ id: 'orb-2', size: 480, initialX: 70, initialY: -5, color: 'radial-gradient(circle, #5EEBC3 25%, rgba(174, 114, 249, 0.6) 70%, rgba(139, 92, 246, 0.3) 100%)', minX: 50, maxX: 110, minY: -30, maxY: 40 },
// Middle-left zone
{ id: 'orb-3', size: 420, initialX: 5, initialY: 35, color: 'radial-gradient(circle, rgba(148, 251, 224, 0.8) 15%, #AE72F9 55%, rgba(94, 235, 195, 0.3) 100%)', minX: -20, maxX: 45, minY: 20, maxY: 65 },
// Middle-right zone
{ id: 'orb-4', size: 400, initialX: 60, initialY: 40, color: 'radial-gradient(circle, rgba(220, 200, 255, 0.95) 10%, rgba(148, 251, 224, 0.85) 45%, rgba(174, 114, 249, 0.5) 100%)', minX: 40, maxX: 100, minY: 25, maxY: 70 },
// Bottom-left zone
{ id: 'orb-5', size: 520, initialX: -8, initialY: 65, color: 'radial-gradient(circle, #B794F6 30%, rgba(148, 251, 224, 0.6) 65%, rgba(174, 114, 249, 0.3) 100%)', minX: -30, maxX: 40, minY: 50, maxY: 110 },
// Bottom-right zone
{ id: 'orb-6', size: 450, initialX: 65, initialY: 70, color: 'radial-gradient(circle, rgba(180, 255, 235, 0.95) 15%, rgba(200, 160, 255, 0.8) 50%, rgba(94, 235, 195, 0.45) 100%)', minX: 45, maxX: 110, minY: 55, maxY: 110 },
];
interface HeroSectionProps {
onAnalyze?: (value: string, type?: SearchType) => void;
onAutocomplete?: (data: AutocompleteRequest) => void;
onManualInput?: (businessName: string, address: string) => void;
onTestData?: (data: CrawlingResponse) => void;
onNext?: () => void;
error?: string | null;
scrollProgress?: number;
}
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = useState('');
const [searchType, setSearchType] = useState<SearchType>('name');
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
const [localError, setLocalError] = useState('');
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
const [testError, setTestError] = useState('');
const [isLoadingTest, setIsLoadingTest] = useState(false);
// 테스트 데이터 로드 핸들러
const handleTestData = async () => {
if (!onTestData) return;
setIsLoadingTest(true);
try {
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
const response = await fetch(jsonFile);
const data: CrawlingResponse = await response.json();
onTestData(data);
} catch (error) {
console.error('테스트 데이터 로드 실패:', error);
setLocalError(t('landing.hero.testDataLoadFailed'));
} finally {
setIsLoadingTest(false);
}
};
const { filteredHistory, showHistory, openHistory, closeHistory, hideOnInput, deleteItem } = useSearchHistory(searchType === 'manual' ? 'name' : searchType);
const [isFocused, setIsFocused] = useState(false);
const [autocompleteResults, setAutocompleteResults] = useState<AccommodationSearchItem[]>([]);
const [isAutocompleteLoading, setIsAutocompleteLoading] = useState(false);
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedItem, setSelectedItem] = useState<AccommodationSearchItem | null>(null);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
const animationRefs = useRef<number[]>([]);
const dropdownRef = useRef<HTMLDivElement>(null);
const autocompleteRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const tutorial = useTutorial();
// 첫 방문 시 랜딩 튜토리얼 시작
@ -113,101 +66,28 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
}
}, []);
const searchTypeOptions = [
{ value: 'url' as SearchType, label: 'URL' },
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
// { value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') },
];
// 드롭다운 외부 클릭 감지
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsDropdownOpen(false);
}
if (autocompleteRef.current && !autocompleteRef.current.contains(event.target as Node)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 업체명 검색 시 자동완성 (디바운스 적용)
const handleAutocompleteSearch = useCallback(async (query: string) => {
if (!query.trim() || searchType !== 'name') {
setAutocompleteResults([]);
setShowAutocomplete(false);
return;
}
setIsAutocompleteLoading(true);
// 테스트 데이터 로드 핸들러
const handleTestData = async () => {
if (!onTestData) return;
setIsLoadingTest(true);
try {
const response = await searchAccommodation(query);
setAutocompleteResults(response.items || []);
setShowAutocomplete(response.items && response.items.length > 0);
} catch (error) {
console.error('자동완성 검색 오류:', error);
setAutocompleteResults([]);
setShowAutocomplete(false);
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
const response = await fetch(jsonFile);
const data: CrawlingResponse = await response.json();
onTestData(data);
} catch {
setTestError(t('landing.hero.testDataLoadFailed'));
} finally {
setIsAutocompleteLoading(false);
}
}, [searchType]);
// 자동완성 항목 선택 - 업체 정보 저장
const handleSelectAutocomplete = (item: AccommodationSearchItem) => {
setInputValue(item.title.replace(/<[^>]*>/g, '')); // HTML 태그 제거
setSelectedItem(item); // 선택된 업체 정보 저장
setShowAutocomplete(false);
setAutocompleteResults([]);
setHighlightedIndex(-1);
};
// 키보드 네비게이션 핸들러
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 자동완성이 표시될 때만 키보드 네비게이션 활성화
if (showAutocomplete && autocompleteResults.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex(prev =>
prev < autocompleteResults.length - 1 ? prev + 1 : 0
);
return;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : autocompleteResults.length - 1
);
return;
case 'Enter':
e.preventDefault(); // 항상 Enter 기본 동작 방지
if (highlightedIndex >= 0 && highlightedIndex < autocompleteResults.length) {
handleSelectAutocomplete(autocompleteResults[highlightedIndex]);
}
return;
case 'Escape':
e.preventDefault();
setShowAutocomplete(false);
setHighlightedIndex(-1);
return;
}
}
// Enter 키 입력 시 기본 동작 방지 (폼 제출 방지)
if (e.key === 'Enter') {
e.preventDefault();
setIsLoadingTest(false);
}
};
// Random movement for orbs
// Orb 랜덤 이동 애니메이션
useEffect(() => {
const moveOrb = (orb: HTMLDivElement, index: number) => {
const config = orbConfigs[index];
let currentX = config.initialX;
let currentY = config.initialY;
// 초기 타겟은 현재 위치와 동일하게 설정 (순간이동 방지)
let targetX = currentX;
let targetY = currentY;
let scale = 1;
@ -215,17 +95,13 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
let isFirstMove = true;
const generateNewTarget = () => {
// Move within the orb's designated zone
const rangeX = config.maxX - config.minX;
const rangeY = config.maxY - config.minY;
if (isFirstMove) {
// 첫 이동은 현재 위치에서 가까운 곳으로 (자연스러운 시작)
const smallRangeX = rangeX * 0.3;
const smallRangeY = rangeY * 0.3;
targetX = currentX + (Math.random() - 0.5) * smallRangeX;
targetY = currentY + (Math.random() - 0.5) * smallRangeY;
// 범위 내로 클램핑
targetX = Math.max(config.minX, Math.min(config.maxX, targetX));
targetY = Math.max(config.minY, Math.min(config.maxY, targetY));
isFirstMove = false;
@ -233,94 +109,38 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
targetX = config.minX + Math.random() * rangeX;
targetY = config.minY + Math.random() * rangeY;
}
targetScale = 0.9 + Math.random() * 0.2; // 0.9 to 1.1 (더 작은 범위)
targetScale = 0.9 + Math.random() * 0.2;
};
const animate = () => {
// Slow speed - 일정한 속도로 부드럽게
const speed = 0.003;
currentX += (targetX - currentX) * speed;
currentY += (targetY - currentY) * speed;
scale += (targetScale - scale) * speed;
// Apply transform
orb.style.left = `${currentX}%`;
orb.style.top = `${currentY}%`;
orb.style.transform = `scale(${scale})`;
// Generate new target when close enough
const distance = Math.sqrt(
Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2)
);
if (distance < 1) {
generateNewTarget();
}
const distance = Math.sqrt(Math.pow(targetX - currentX, 2) + Math.pow(targetY - currentY, 2));
if (distance < 1) generateNewTarget();
animationRefs.current[index] = requestAnimationFrame(animate);
};
// 첫 타겟 생성 후 애니메이션 시작
generateNewTarget();
animate();
};
// 모든 공이 동시에 자연스럽게 시작 (딜레이 없이)
orbRefs.current.forEach((orb, index) => {
if (orb) {
moveOrb(orb, index);
}
if (orb) moveOrb(orb, index);
});
// Cleanup
return () => {
animationRefs.current.forEach(id => cancelAnimationFrame(id));
};
}, []);
const error = externalError || localError;
const getPlaceholder = () => {
return searchType === 'url'
? 'https://naver.me/abcdef'
: t('landing.hero.placeholderBusinessName');
};
const getGuideText = () => {
return searchType === 'url'
? t('landing.hero.guideUrl')
: t('landing.hero.guideBusinessName');
};
const handleStart = () => {
if (!inputValue.trim()) {
setLocalError(searchType === 'url' ? t('landing.hero.errorUrlRequired') : t('landing.hero.errorNameRequired'));
return;
}
if (searchType === 'url' && !isValidUrl(inputValue.trim())) {
setLocalError(t('landing.hero.errorInvalidUrl'));
return;
}
setLocalError('');
if (searchType === 'name' && selectedItem && onAutocomplete) {
// 업체명 검색인 경우 autocomplete API 호출
const request: AutocompleteRequest = {
address: selectedItem.address,
roadAddress: selectedItem.roadAddress,
title: selectedItem.title,
};
onAutocomplete(request);
} else if (onAnalyze) {
// URL 검색인 경우 기존 로직
onAnalyze(inputValue, searchType);
}
};
return (
<div className="hero-section">
{/* Animated background orbs - 스크롤에 따라 빠르게 사라짐 */}
{/* Animated background orbs */}
<div
className="hero-bg-orbs"
style={{ opacity: Math.max(0, 1 - scrollProgress * 3) }}
@ -342,219 +162,45 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
</div>
<div className="hero-content">
{/* Logo Image */}
<img
src="/assets/images/ado2-logo.svg"
src="/assets/images/ADO2_with Slogan_white.svg"
alt="ADO2"
className="hero-logo"
/>
{/* Input Form */}
<div className="hero-form">
<div className={`hero-input-wrapper ${isFocused ? 'focused' : ''} ${error ? 'error' : ''} ${inputValue && !isFocused ? 'filled' : ''}`}>
{/* 드롭다운 */}
<div className="hero-dropdown-container" ref={dropdownRef}>
<button
type="button"
className="hero-dropdown-trigger"
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
>
<span>{searchTypeOptions.find(opt => opt.value === searchType)?.label}</span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={`hero-dropdown-arrow ${isDropdownOpen ? 'open' : ''}`}
>
<path d="M6 9l6 6 6-6" />
</svg>
</button>
{isDropdownOpen && (
<div className="hero-dropdown-menu">
{searchTypeOptions.map((option) => (
<button
key={option.value}
type="button"
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
onClick={() => {
if (option.value === 'manual') {
<SearchInputForm
onAnalyze={onAnalyze}
onAutocomplete={onAutocomplete}
onManualInput={onManualInput}
onManualButtonClick={() => {
if (isLoggedIn()) {
setIsManualModalOpen(true);
setIsDropdownOpen(false);
} else {
setSearchType(option.value);
setIsDropdownOpen(false);
setIsLoginPromptOpen(true);
}
}}
>
{option.label}
</button>
))}
</div>
)}
</div>
<div className="hero-input-container" ref={autocompleteRef}>
<input
type="text"
value={inputValue}
onChange={(e) => {
let value = e.target.value;
// URL 모드일 때 URL만 추출 (예: "[네이버 지도] https://...")
if (searchType === 'url') {
const urlMatch = value.match(/https?:\/\/\S+/);
if (urlMatch && urlMatch[0] !== value) {
value = urlMatch[0];
}
}
setInputValue(value);
setHighlightedIndex(-1);
hideOnInput(value);
if (localError) setLocalError('');
// 업체명 검색일 때 자동완성 검색 (디바운스)
if (searchType === 'name') {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleAutocompleteSearch(value);
}, 300);
}
}}
onPaste={(e: React.ClipboardEvent<HTMLInputElement>) => {
if (searchType !== 'url') return;
const pasted = e.clipboardData.getData('text');
const urlMatch = pasted.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
e.preventDefault();
setInputValue(urlMatch[0]);
if (localError) setLocalError('');
}
}}
onFocus={() => {
setIsFocused(true);
if (searchType === 'name' && autocompleteResults.length > 0) {
setShowAutocomplete(true);
}
openHistory(inputValue);
}}
onBlur={() => { setIsFocused(false); setTimeout(() => closeHistory(), 150); }}
onKeyDown={handleKeyDown}
placeholder={getPlaceholder()}
className={`hero-input ${inputValue ? 'has-value' : ''}`}
error={externalError || testError || null}
/>
{/* 최근 검색 히스토리 */}
{showHistory && !showAutocomplete && (
<SearchHistoryDropdown
items={filteredHistory}
onSelect={(item) => {
closeHistory();
if (item.type === 'url') {
setSearchType('url');
setInputValue(item.value);
} else {
setSearchType('name');
setInputValue(item.value);
setSelectedItem({ title: item.value, address: item.address || '', roadAddress: item.roadAddress || '' });
}
}}
onDelete={(e, value) => { e.preventDefault(); e.stopPropagation(); deleteItem(value); }}
className="hero-autocomplete-dropdown"
itemClassName="hero-autocomplete-item"
titleClassName="hero-autocomplete-title"
addressClassName="hero-autocomplete-address"
/>
)}
{/* 자동완성 결과 */}
{showAutocomplete && searchType === 'name' && (
<div className="hero-autocomplete-dropdown">
{isAutocompleteLoading ? (
<div className="hero-autocomplete-loading">{t('landing.hero.searching')}</div>
) : (
autocompleteResults.map((item, index) => (
<button
key={index}
type="button"
className={`hero-autocomplete-item ${highlightedIndex === index ? 'highlighted' : ''}`}
onMouseDown={(e) => {
e.preventDefault();
handleSelectAutocomplete(item);
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
<div className="hero-autocomplete-title" dangerouslySetInnerHTML={{ __html: item.title }} />
<div className="hero-autocomplete-address">{item.roadAddress || item.address}</div>
</button>
))
)}
</div>
)}
</div>
{inputValue && (
<button
type="button"
className="hero-input-clear"
onClick={() => {
setInputValue('');
setLocalError('');
setAutocompleteResults([]);
setShowAutocomplete(false);
}}
>
<img src="/assets/images/input-clear-icon.svg" alt="Clear" />
</button>
)}
</div>
<span className="hero-input-hint">{getGuideText()}</span>
{error && (
<p className="hero-error">{error}</p>
)}
<button onClick={handleStart} className="hero-button">
{t('landing.hero.analyzeButton')}
</button>
</div>
</div>
{/* Footer Indicator */}
<button onClick={onNext} className="scroll-indicator">
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
<div className="scroll-indicator-icon">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
<path d="M7 10l5 5 5-5" />
</svg>
</div>
</button>
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
{/* 테스트 버튼 */}
{isTestPage && onTestData && (
<button
onClick={handleTestData}
disabled={isLoadingTest}
className="test-data-button"
>
<button onClick={handleTestData} disabled={isLoadingTest} className="test-data-button">
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
</button>
)}
{tutorial.isActive && (
{tutorial.isActive && !isManualModalOpen && (
<TutorialOverlay
hints={tutorial.hints}
currentIndex={tutorial.currentHintIndex}
@ -568,9 +214,17 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
{isManualModalOpen && (
<BusinessNameInputModal
onClose={() => setIsManualModalOpen(false)}
onSubmit={(businessName, address) => { setIsManualModalOpen(false); onManualInput?.(businessName, address); }}
onSubmit={(businessName, address) => {
if (tutorial.isActive) tutorial.nextHint();
setIsManualModalOpen(false);
onManualInput?.(businessName, address);
}}
/>
)}
{isLoginPromptOpen && (
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
)}
</div>
);
};

View File

@ -77,7 +77,7 @@ const LoginSection: React.FC<LoginSectionProps> = ({ onBack, onLogin }) => {
<div className="login-content">
{/* Logo */}
<div className="login-logo">
<img src="/assets/images/ado2-login-logo.svg" alt="ADO2" />
<img src="/assets/images/ADO2_with Slogan_white.svg" alt="ADO2" />
</div>
{/* Error Message */}