feat: add generating image page, artwork, and map translation

* feat: add options/map flow, dev seed, and artwork fixes

Options page, Kakao map with florist order message, dev tooling, and
create/message dummy gating — without secrets in .env.example.

Co-authored-by: Cursor <cursoragent@cursor.com>

* with generating page + art work

---------

Co-authored-by: 이지은 <ijieun@ijieun-ui-MacBookPro.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chaewon Lee
2026-06-14 09:43:35 +09:00
committed by GitHub
parent 921dfd55f4
commit 80b84bd2ed
25 changed files with 851 additions and 110 deletions

View File

@@ -0,0 +1,6 @@
<svg width="328" height="443" viewBox="0 0 328 443" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M186.866 226L76 271L186.866 301L265.5 264L186.866 226Z" fill="#7D7D7D"/>
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
</svg>

After

Width:  |  Height:  |  Size: 515 B

View File

@@ -0,0 +1,24 @@
<svg width="328" height="443" viewBox="0 0 328 443" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M186.866 226L76 271L186.866 301L265.5 264L186.866 226Z" fill="#7D7D7D"/>
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
<path d="M186.866 226L76 271L186.866 301L265.5 264L186.866 226Z" fill="#7D7D7D"/>
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M158 316.5C176.167 299.333 208 252.7 190 203.5C167.5 142 206.5 90 221.5 88" stroke="#7D7D7D" stroke-width="6"/>
<path d="M158 316.5C176.167 299.333 208 252.7 190 203.5C167.5 142 206.5 90 221.5 88" stroke="url(#paint0_linear_391_391)" stroke-width="6"/>
<foreignObject x="137" y="68" width="111" height="91"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_0_391_391_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="147" y="78" width="91" height="71" fill="#F1C130"/>
<foreignObject x="116.507" y="33.507" width="60.9719" height="49.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_1_391_391_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="122" y="39" width="49.9859" height="39" fill="#EDBA54"/>
<foreignObject x="128.304" y="59.3043" width="80.3913" height="80.3913"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(9.35px);clip-path:url(#bgblur_2_391_391_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="18.6957" x="147" y="78" width="43" height="43" fill="#F19430"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
<defs>
<clipPath id="bgblur_0_391_391_clip_path" transform="translate(-137 -68)"><rect x="147" y="78" width="91" height="71"/>
</clipPath><clipPath id="bgblur_1_391_391_clip_path" transform="translate(-116.507 -33.507)"><rect x="122" y="39" width="49.9859" height="39"/>
</clipPath><clipPath id="bgblur_2_391_391_clip_path" transform="translate(-128.304 -59.3043)"><rect x="147" y="78" width="43" height="43"/>
</clipPath><linearGradient id="paint0_linear_391_391" x1="234.304" y1="63" x2="234.304" y2="316.5" gradientUnits="userSpaceOnUse">
<stop offset="0.355769" stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,34 @@
<svg width="328" height="443" viewBox="0 0 328 443" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M186.866 226L76 271L186.866 301L265.5 264L186.866 226Z" fill="#7D7D7D"/>
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
<path d="M186.866 226L76 271L186.866 301L265.5 264L186.866 226Z" fill="#7D7D7D"/>
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M151.5 369.5C133.333 352.333 125 274.2 143 225C165.5 163.5 103 143 88 141" stroke="#7D7D7D" stroke-width="6"/>
<path d="M151.5 369.5C133.333 352.333 125 274.2 143 225C165.5 163.5 103 143 88 141" stroke="url(#paint0_linear_391_397)" stroke-width="6"/>
<path d="M161 349.5C179.167 332.333 211 285.7 193 236.5C170.5 175 209.5 123 224.5 121" stroke="#7D7D7D" stroke-width="6"/>
<path d="M161 349.5C179.167 332.333 211 285.7 193 236.5C170.5 175 209.5 123 224.5 121" stroke="url(#paint1_linear_391_397)" stroke-width="6"/>
<rect x="186.535" y="85.1172" width="53.4653" height="50.6139" fill="#D9D9D9"/>
<rect x="168" y="73" width="28.5149" height="12.1188" fill="#D9D9D9"/>
<rect x="224.317" y="135.734" width="15.6832" height="12.1188" fill="#D9D9D9"/>
<rect x="208.634" y="85.1172" width="31.3663" height="33.505" fill="#BABABA"/>
<foreignObject x="70" y="129" width="111" height="91"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_0_391_397_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="80" y="139" width="91" height="71" fill="#F1C130"/>
<foreignObject x="49.507" y="94.507" width="60.9718" height="49.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_1_391_397_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="55" y="100" width="49.9859" height="39" fill="#EDBA54"/>
<foreignObject x="61.3043" y="120.304" width="80.3913" height="80.3913"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(9.35px);clip-path:url(#bgblur_2_391_397_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="18.6957" x="80" y="139" width="43" height="43" fill="#F19430"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
<defs>
<clipPath id="bgblur_0_391_397_clip_path" transform="translate(-70 -129)"><rect x="80" y="139" width="91" height="71"/>
</clipPath><clipPath id="bgblur_1_391_397_clip_path" transform="translate(-49.507 -94.507)"><rect x="55" y="100" width="49.9859" height="39"/>
</clipPath><clipPath id="bgblur_2_391_397_clip_path" transform="translate(-61.3043 -120.304)"><rect x="80" y="139" width="43" height="43"/>
</clipPath><linearGradient id="paint0_linear_391_397" x1="75.1957" y1="116" x2="75.1957" y2="369.5" gradientUnits="userSpaceOnUse">
<stop offset="0.355769" stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
<linearGradient id="paint1_linear_391_397" x1="237.304" y1="96" x2="237.304" y2="349.5" gradientUnits="userSpaceOnUse">
<stop offset="0.355769" stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,39 @@
<svg width="328" height="443" viewBox="0 0 328 443" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M186.866 226L76 271L186.866 301L265.5 264L186.866 226Z" fill="#7D7D7D"/>
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
<path d="M187.866 226L77 271L187.866 301L266.5 264L187.866 226Z" fill="#7D7D7D"/>
<path d="M169.14 433L121 424.25L141.632 405L179.8 394L219 423.5L169.14 433Z" fill="#7D7D7D"/>
<path d="M140 326.5C158.167 309.333 196 278 184.5 227C170.095 163.117 192 152 208.5 136.5C219.529 126.139 272 124.5 261 74" stroke="#7D7D7D" stroke-width="6"/>
<path d="M140 326.5C158.167 309.333 196 278 184.5 227C170.095 163.117 192 152 208.5 136.5C219.529 126.139 272 124.5 261 74" stroke="url(#paint0_linear_391_403)" stroke-width="6"/>
<path d="M174.049 387C161.203 369.8 147.608 316.064 160.336 266.77C176.247 205.152 137.549 195.5 111.549 189.5C101.031 187.073 68.4078 176.597 76.1864 126" stroke="url(#paint1_linear_391_403)" stroke-width="6"/>
<rect x="225" y="27" width="75" height="71" fill="#D9D9D9"/>
<rect x="199" y="10" width="40" height="17" fill="#D9D9D9"/>
<rect x="278" y="98" width="22" height="17" fill="#D9D9D9"/>
<rect x="256" y="27" width="44" height="47" fill="#BABABA"/>
<foreignObject x="118" y="99" width="111" height="91"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_0_391_403_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="128" y="109" width="91" height="71" fill="#F1C130"/>
<foreignObject x="197.507" y="174.507" width="42.9859" height="33.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_1_391_403_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="203" y="180" width="32" height="23" fill="#FF8400"/>
<foreignObject x="38" y="68" width="80" height="80"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_2_391_403_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="48" y="78" width="60" height="60" fill="#D14B16"/>
<foreignObject x="95" y="22" width="43" height="43"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_3_391_403_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="105" y="32" width="23" height="23" fill="#7C1401"/>
<foreignObject x="109.304" y="90.3043" width="80.3913" height="80.3913"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(9.35px);clip-path:url(#bgblur_4_391_403_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="18.6957" x="128" y="109" width="43" height="43" fill="#F19430"/>
<foreignObject x="59" y="45" width="59" height="43"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_5_391_403_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="69" y="78" width="23" height="39" transform="rotate(-90 69 78)" fill="#ED6454"/>
<path d="M198 288.566L77 271L134.932 360.865V372.576L121.295 424L176.523 402.87V372.576L198 288.566Z" fill="#BBBBBB"/>
<path d="M137 307.86L266.5 264L203.383 360.612V371.263L218.362 423L180.915 412.095L137 307.86Z" fill="#D9D9D9"/>
<defs>
<clipPath id="bgblur_0_391_403_clip_path" transform="translate(-118 -99)"><rect x="128" y="109" width="91" height="71"/>
</clipPath><clipPath id="bgblur_1_391_403_clip_path" transform="translate(-197.507 -174.507)"><rect x="203" y="180" width="32" height="23"/>
</clipPath><clipPath id="bgblur_2_391_403_clip_path" transform="translate(-38 -68)"><rect x="48" y="78" width="60" height="60"/>
</clipPath><clipPath id="bgblur_3_391_403_clip_path" transform="translate(-95 -22)"><rect x="105" y="32" width="23" height="23"/>
</clipPath><clipPath id="bgblur_4_391_403_clip_path" transform="translate(-109.304 -90.3043)"><rect x="128" y="109" width="43" height="43"/>
</clipPath><clipPath id="bgblur_5_391_403_clip_path" transform="translate(-59 -45)"><rect x="69" y="78" width="23" height="39" transform="rotate(-90 69 78)"/>
</clipPath><linearGradient id="paint0_linear_391_403" x1="216.304" y1="73" x2="216.304" y2="326.5" gradientUnits="userSpaceOnUse">
<stop offset="0.355769" stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
<linearGradient id="paint1_linear_391_403" x1="129.007" y1="126" x2="129.007" y2="379.987" gradientUnits="userSpaceOnUse">
<stop stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,50 @@
<svg width="328" height="443" viewBox="0 0 328 443" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M186.866 226L76 271L186.866 301L265.5 264L186.866 226Z" fill="#7D7D7D"/>
<path d="M186.866 226L76 271L186.866 301L265.5 264L186.866 226Z" fill="#7D7D7D"/>
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M159 326.5C177.167 309.333 215 278 203.5 227C189.095 163.117 211 152 227.5 136.5C238.529 126.139 291 124.5 280 74" stroke="#7D7D7D" stroke-width="6"/>
<path d="M159 326.5C177.167 309.333 215 278 203.5 227C189.095 163.117 211 152 227.5 136.5C238.529 126.139 291 124.5 280 74" stroke="url(#paint0_linear_391_351)" stroke-width="6"/>
<path d="M193 386.5C174.833 369.333 155.608 315.7 173.608 266.5C196.108 205 122.108 190.5 107.108 188.5C92.1079 186.5 43.6079 176.5 54.6079 126" stroke="url(#paint1_linear_391_351)" stroke-width="6"/>
<rect x="244" y="27" width="75" height="71" fill="#D9D9D9"/>
<rect x="218" y="10" width="40" height="17" fill="#D9D9D9"/>
<rect x="297" y="98" width="22" height="17" fill="#D9D9D9"/>
<rect x="275" y="27" width="44" height="47" fill="#BABABA"/>
<foreignObject x="137" y="109" width="111" height="91"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_0_391_351_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="147" y="119" width="91" height="71" fill="#F1C130"/>
<foreignObject x="216.507" y="184.507" width="42.9859" height="33.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_1_391_351_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="222" y="190" width="32" height="23" fill="#FF8400"/>
<foreignObject x="-1" y="79" width="80" height="80"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_2_391_351_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="9" y="89" width="60" height="60" fill="#D14B16"/>
<foreignObject x="56" y="33" width="43" height="43"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_3_391_351_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="66" y="43" width="23" height="23" fill="#7C1401"/>
<foreignObject x="128.304" y="100.304" width="80.3913" height="80.3913"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(9.35px);clip-path:url(#bgblur_4_391_351_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="18.6957" x="147" y="119" width="43" height="43" fill="#F19430"/>
<foreignObject x="20" y="56" width="59" height="43"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_5_391_351_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="30" y="89" width="23" height="39" transform="rotate(-90 30 89)" fill="#ED6454"/>
<path d="M197.028 348C163.862 314.667 100.428 253 118.028 199C140.68 129.5 128.862 85 122.528 71" stroke="url(#paint2_linear_391_351)" stroke-width="6"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
<foreignObject x="100.507" y="49.507" width="69.9859" height="49.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_6_391_351_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="106" y="55" width="59" height="39" fill="#FFB4DD"/>
<foreignObject x="133.507" y="49.507" width="36.9859" height="25.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_7_391_351_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="139" y="55" width="26" height="15" fill="#FF5EB7"/>
<foreignObject x="139.507" y="88.507" width="41.9859" height="23.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_8_391_351_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="145" y="94" width="31" height="13" fill="#C3438A"/>
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
<defs>
<clipPath id="bgblur_0_391_351_clip_path" transform="translate(-137 -109)"><rect x="147" y="119" width="91" height="71"/>
</clipPath><clipPath id="bgblur_1_391_351_clip_path" transform="translate(-216.507 -184.507)"><rect x="222" y="190" width="32" height="23"/>
</clipPath><clipPath id="bgblur_2_391_351_clip_path" transform="translate(1 -79)"><rect x="9" y="89" width="60" height="60"/>
</clipPath><clipPath id="bgblur_3_391_351_clip_path" transform="translate(-56 -33)"><rect x="66" y="43" width="23" height="23"/>
</clipPath><clipPath id="bgblur_4_391_351_clip_path" transform="translate(-128.304 -100.304)"><rect x="147" y="119" width="43" height="43"/>
</clipPath><clipPath id="bgblur_5_391_351_clip_path" transform="translate(-20 -56)"><rect x="30" y="89" width="23" height="39" transform="rotate(-90 30 89)"/>
</clipPath><clipPath id="bgblur_6_391_351_clip_path" transform="translate(-100.507 -49.507)"><rect x="106" y="55" width="59" height="39"/>
</clipPath><clipPath id="bgblur_7_391_351_clip_path" transform="translate(-133.507 -49.507)"><rect x="139" y="55" width="26" height="15"/>
</clipPath><clipPath id="bgblur_8_391_351_clip_path" transform="translate(-139.507 -88.507)"><rect x="145" y="94" width="31" height="13"/>
</clipPath><linearGradient id="paint0_linear_391_351" x1="235.304" y1="73" x2="235.304" y2="326.5" gradientUnits="userSpaceOnUse">
<stop offset="0.355769" stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
<linearGradient id="paint1_linear_391_351" x1="129.304" y1="126" x2="129.304" y2="379.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
<linearGradient id="paint2_linear_391_351" x1="156.735" y1="71" x2="156.735" y2="348" gradientUnits="userSpaceOnUse">
<stop stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -0,0 +1,50 @@
<svg width="328" height="443" viewBox="0 0 328 443" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M186.866 226L76 271L186.866 301L265.5 264L186.866 226Z" fill="#7D7D7D"/>
<path d="M168.14 433L120 424.25L140.632 405L178.8 394L218 423.5L168.14 433Z" fill="#7D7D7D"/>
<path d="M159 326.5C177.167 309.333 215 278 203.5 227C189.095 163.117 211 152 227.5 136.5C238.529 126.139 291 124.5 280 74" stroke="#7D7D7D" stroke-width="6"/>
<path d="M159 326.5C177.167 309.333 215 278 203.5 227C189.095 163.117 211 152 227.5 136.5C238.529 126.139 291 124.5 280 74" stroke="url(#paint0_linear_391_409)" stroke-width="6"/>
<path d="M193 386.5C174.833 369.333 155.608 315.7 173.608 266.5C196.108 205 122.108 190.5 107.108 188.5C92.1079 186.5 43.6079 176.5 54.6079 126" stroke="url(#paint1_linear_391_409)" stroke-width="6"/>
<rect x="244" y="27" width="75" height="71" fill="#D9D9D9"/>
<rect x="218" y="10" width="40" height="17" fill="#D9D9D9"/>
<rect x="297" y="98" width="22" height="17" fill="#D9D9D9"/>
<rect x="275" y="27" width="44" height="47" fill="#BABABA"/>
<foreignObject x="137" y="109" width="111" height="91"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_0_391_409_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="147" y="119" width="91" height="71" fill="#F1C130"/>
<foreignObject x="216.507" y="184.507" width="42.9859" height="33.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_1_391_409_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="222" y="190" width="32" height="23" fill="#FF8400"/>
<foreignObject x="-1" y="79" width="80" height="80"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_2_391_409_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="9" y="89" width="60" height="60" fill="#D14B16"/>
<foreignObject x="56" y="33" width="43" height="43"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_3_391_409_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="66" y="43" width="23" height="23" fill="#7C1401"/>
<foreignObject x="128.304" y="100.304" width="80.3913" height="80.3913"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(9.35px);clip-path:url(#bgblur_4_391_409_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="18.6957" x="147" y="119" width="43" height="43" fill="#F19430"/>
<foreignObject x="20" y="56" width="59" height="43"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(5px);clip-path:url(#bgblur_5_391_409_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="10" x="30" y="89" width="23" height="39" transform="rotate(-90 30 89)" fill="#ED6454"/>
<path d="M197.028 348C163.862 314.667 100.428 253 118.028 199C140.68 129.5 128.862 85 122.528 71" stroke="url(#paint2_linear_391_409)" stroke-width="6"/>
<path d="M197 288.566L76 271L133.932 360.865V372.576L120.295 424L175.523 402.87V372.576L197 288.566Z" fill="#BBBBBB"/>
<path d="M136 307.86L265.5 264L202.383 360.612V371.263L217.362 423L179.915 412.095L136 307.86Z" fill="#D9D9D9"/>
<foreignObject x="100.507" y="49.507" width="69.9859" height="49.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_6_391_409_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="106" y="55" width="59" height="39" fill="#FFB4DD"/>
<foreignObject x="133.507" y="49.507" width="36.9859" height="25.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_7_391_409_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="139" y="55" width="26" height="15" fill="#FF5EB7"/>
<foreignObject x="139.507" y="88.507" width="41.9859" height="23.9859"><div xmlns="http://www.w3.org/1999/xhtml" style="backdrop-filter:blur(2.75px);clip-path:url(#bgblur_8_391_409_clip_path);height:100%;width:100%"></div></foreignObject><rect data-figma-bg-blur-radius="5.49296" x="145" y="94" width="31" height="13" fill="#C3438A"/>
<path d="M134 373V360.5H202.5V371.5L134 373Z" fill="#7D7D7D"/>
<path d="M157 357L177.5 355.5L176 376L157 373V357Z" fill="#7D7D7D"/>
<path d="M123 394.5L171 363L173.5 370L134 402.5H127L123 394.5Z" fill="#7D7D7D"/>
<path d="M198.5 382.5L163.5 361L161 368L187.5 390.5H194.5L198.5 382.5Z" fill="#7D7D7D"/>
<defs>
<clipPath id="bgblur_0_391_409_clip_path" transform="translate(-137 -109)"><rect x="147" y="119" width="91" height="71"/>
</clipPath><clipPath id="bgblur_1_391_409_clip_path" transform="translate(-216.507 -184.507)"><rect x="222" y="190" width="32" height="23"/>
</clipPath><clipPath id="bgblur_2_391_409_clip_path" transform="translate(1 -79)"><rect x="9" y="89" width="60" height="60"/>
</clipPath><clipPath id="bgblur_3_391_409_clip_path" transform="translate(-56 -33)"><rect x="66" y="43" width="23" height="23"/>
</clipPath><clipPath id="bgblur_4_391_409_clip_path" transform="translate(-128.304 -100.304)"><rect x="147" y="119" width="43" height="43"/>
</clipPath><clipPath id="bgblur_5_391_409_clip_path" transform="translate(-20 -56)"><rect x="30" y="89" width="23" height="39" transform="rotate(-90 30 89)"/>
</clipPath><clipPath id="bgblur_6_391_409_clip_path" transform="translate(-100.507 -49.507)"><rect x="106" y="55" width="59" height="39"/>
</clipPath><clipPath id="bgblur_7_391_409_clip_path" transform="translate(-133.507 -49.507)"><rect x="139" y="55" width="26" height="15"/>
</clipPath><clipPath id="bgblur_8_391_409_clip_path" transform="translate(-139.507 -88.507)"><rect x="145" y="94" width="31" height="13"/>
</clipPath><linearGradient id="paint0_linear_391_409" x1="235.304" y1="73" x2="235.304" y2="326.5" gradientUnits="userSpaceOnUse">
<stop offset="0.355769" stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
<linearGradient id="paint1_linear_391_409" x1="129.304" y1="126" x2="129.304" y2="379.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
<linearGradient id="paint2_linear_391_409" x1="156.735" y1="71" x2="156.735" y2="348" gradientUnits="userSpaceOnUse">
<stop stop-color="#523E03"/>
<stop offset="1" stop-color="#08B816"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -2,33 +2,50 @@
// The exhibited artwork — always shown on the left, acting like a step indicator.
import Vase from './Vase.svelte';
import DescriptionCard from './DescriptionCard.svelte';
import ComingSoonTape from './ComingSoonTape.svelte';
let {
title = 'Title',
description = 'Description Description Description',
/** options Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
imageSrc = null
/** @type {import('./artworkVariants.js').ArtworkVariant} */
variant = 'create1',
/** edit Continue 이후 확정된 꽃다발만 전달 (그 전에는 null → Vase) */
imageSrc = null,
/** generating 단계: 작품 중앙 Coming Soon 밴드 */
comingSoon = false
} = $props();
</script>
<section
class="flex w-full shrink-0 flex-col border-b border-line lg:min-h-0 lg:w-[44%] lg:shrink-0 lg:overflow-y-auto lg:border-r lg:border-b-0"
class="relative flex w-full shrink-0 flex-col border-b border-line lg:min-h-0 lg:h-full lg:w-[44%] lg:shrink-0 lg:overflow-y-auto lg:border-r lg:border-b-0"
>
<!-- mobile: compact row · desktop: centered column -->
<!--
mobile: row · desktop: 꽃 슬롯 높이 고정 → 설명 카드 길이와 무관하게 Y·크기 유지
-->
<div
class="mx-auto flex w-full max-w-100 flex-row items-center gap-12 px-6 py-5 lg:flex-1 lg:flex-col lg:items-center lg:justify-center lg:gap-10 lg:px-6 lg:py-12"
class="mx-auto flex min-h-0 w-full max-w-100 flex-1 flex-row items-start gap-8 px-6 py-6 lg:flex-col lg:items-center lg:justify-start lg:gap-4 lg:px-6 lg:pb-8 lg:pt-[calc(50%-5rem)]"
>
{#if imageSrc}
<div class="mx-auto w-full max-w-24 shrink-0 overflow-hidden sm:max-w-28 lg:max-w-75">
<img
src={imageSrc}
alt="Selected bouquet"
class="aspect-[3/4] h-auto w-full object-cover"
/>
</div>
{:else}
<Vase />
{/if}
<DescriptionCard {title} {description} />
<div
class="flex h-[11rem] shrink-0 items-end justify-center sm:h-[13rem] lg:h-[min(24rem,36vh)] lg:w-full"
>
{#if imageSrc}
<div class="mx-auto w-full max-w-24 shrink-0 overflow-hidden sm:max-w-28 lg:max-w-75">
<img
src={imageSrc}
alt="Selected bouquet"
class="aspect-[3/4] h-auto w-full object-cover"
/>
</div>
{:else}
<Vase {variant} />
{/if}
</div>
<div class="min-w-0 shrink-0 lg:w-full lg:flex lg:justify-center">
<DescriptionCard {title} {description} />
</div>
</div>
{#if comingSoon}
<ComingSoonTape />
{/if}
</section>

View File

@@ -0,0 +1,11 @@
<!-- generating: 왼쪽 섹션 전체 가로 + blur, vase 높이 중앙 -->
<div
class="pointer-events-none absolute inset-x-0 top-[calc(50%-1.25rem)] z-30 -translate-y-1/2"
aria-hidden="true"
>
<div
class="w-full border-y border-subtle/30 bg-muted/45 py-2.5 text-center backdrop-blur-md lg:py-3"
>
<p class="text-sm font-light tracking-wide text-ink lg:text-base">Coming Soon</p>
</div>
</div>

View File

@@ -1,11 +1,16 @@
<script>
import vaseIllustration from '$lib/assets/vase-illustration.svg';
import { getArtworkSrc } from './artworkVariants.js';
/** @type {import('./artworkVariants.js').ArtworkVariant} */
let { variant = 'create1' } = $props();
const src = $derived(getArtworkSrc(variant));
</script>
<img
src={vaseIllustration}
{src}
alt=""
class="mx-auto h-auto w-full max-w-24 shrink-0 sm:max-w-28 lg:max-w-75"
width="320"
height="452"
width="328"
height="443"
/>

View File

@@ -0,0 +1,32 @@
import create1 from '$lib/assets/artwork/1.create1.svg';
import create2 from '$lib/assets/artwork/2.create2.svg';
import upload1 from '$lib/assets/artwork/3.upload1.svg';
import upload2 from '$lib/assets/artwork/4.upload2.svg';
import message1 from '$lib/assets/artwork/5.message1.svg';
import generated from '$lib/assets/artwork/6.generated.svg';
/** @typedef {'create1' | 'create2' | 'upload1' | 'upload2' | 'message1' | 'generated'} ArtworkVariant */
/** @type {Record<ArtworkVariant, string>} */
export const ARTWORK_SRC = {
create1,
create2,
upload1,
upload2,
message1,
generated
};
/** generating 페이지 순환 프레임 */
export const GENERATING_ARTWORK_CYCLE = /** @type {const} */ ([
'create2',
'upload1',
'upload2',
'message1',
'generated'
]);
/** @param {ArtworkVariant} [variant='create1'] */
export function getArtworkSrc(variant = 'create1') {
return ARTWORK_SRC[variant] ?? ARTWORK_SRC.create1;
}

View File

@@ -0,0 +1,64 @@
<script>
import GenerationStepItem from './GenerationStepItem.svelte';
import { GENERATION_STEPS, GENERATION_STEP_COUNT } from './generationSteps.js';
let {
/** 현재 active 단계 (06). GENERATION_STEP_COUNT이면 전부 완료 */
activeStepIndex = 0,
error = '',
retryLabel = '',
canRetry = false,
onRetry = () => {},
onBack = () => {}
} = $props();
/**
* @param {number} index
* @returns {'completed' | 'active' | 'pending'}
*/
function stepStatus(index) {
if (activeStepIndex >= GENERATION_STEP_COUNT) return 'completed';
if (index < activeStepIndex) return 'completed';
if (index === activeStepIndex) return 'active';
return 'pending';
}
</script>
<div class="flex flex-1 flex-col justify-center px-6 py-10 md:px-12 lg:px-16 lg:py-16">
<header class="mb-10 space-y-3 lg:mb-14">
<h1 class="text-3xl leading-relaxed font-light text-muted md:text-4xl lg:text-[2.75rem]">
Creating your bouquet...
</h1>
{#if retryLabel}
<p class="text-sm text-muted">{retryLabel}</p>
{/if}
</header>
<ol class="space-y-4 lg:space-y-5" aria-label="Bouquet creation progress">
{#each GENERATION_STEPS as label, index (label)}
<GenerationStepItem {label} status={stepStatus(index)} />
{/each}
</ol>
{#if error}
<div class="mt-10 space-y-4">
<p class="rounded bg-surface/95 px-3 py-2 text-sm text-red-600 ring-1 ring-black/5">
{error}
</p>
<div class="flex flex-wrap gap-3">
{#if canRetry}
<button type="button" class="bg-pill px-4 py-2 text-sm text-surface" onclick={onRetry}>
Try again
</button>
{/if}
<button
type="button"
class="border border-pill px-4 py-2 text-sm text-ink"
onclick={onBack}
>
Back to message
</button>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,41 @@
<script>
/** @type {'completed' | 'active' | 'pending'} */
let { label, status = 'pending' } = $props();
</script>
<li
class={[
'flex items-start gap-3 text-lg tracking-wide transition-all duration-500 md:text-xs',
status === 'completed' && 'step-completed text-ink opacity-100',
status === 'active' && 'text-ink',
status === 'pending' && 'text-muted/50'
]}
>
<span class="mt-0.5 w-5 shrink-0 text-center leading-none" aria-hidden="true">
{#if status === 'completed'}
<span class="inline-block"></span>
{:else if status === 'active'}
<span class="inline-block animate-pulse"></span>
{:else}
<span class="inline-block"></span>
{/if}
</span>
<span class="leading-snug">{label}</span>
</li>
<style>
.step-completed {
animation: stepFadeIn 0.5s ease-out;
}
@keyframes stepFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,12 @@
/** AI activity feed 단계 라벨 (generating 페이지) */
export const GENERATION_STEPS = [
'Understanding who this bouquet is for',
'Discovering their mood and aesthetic',
'Finding flowers that match their personality',
'Exploring meaningful flower symbolism',
'Designing the bouquet composition',
'Choosing the wrapping style',
'Adding the finishing ribbon'
];
export const GENERATION_STEP_COUNT = GENERATION_STEPS.length;

View File

@@ -1,20 +1,49 @@
<script>
/** @typedef {{ text: string, highlight: boolean }} OrderMessageSegment */
let {
plainText = '',
segments = /** @type {OrderMessageSegment[]} */ ([])
enPlainText = '',
koPlainText = ''
} = $props();
/** @type {'ko' | 'en'} */
let activeLang = $state('ko');
let textEn = $state('');
let textKo = $state('');
let seeded = $state(false);
let copied = $state(false);
const hasMessage = $derived(Boolean(plainText?.trim()));
$effect(() => {
if (!seeded && (enPlainText || koPlainText)) {
textEn = enPlainText;
textKo = koPlainText;
seeded = true;
}
});
const activeText = $derived(activeLang === 'ko' ? textKo : textEn);
const hasMessage = $derived(
Boolean(activeText?.trim()) || Boolean(textEn?.trim()) || Boolean(textKo?.trim())
);
/** @param {Event & { currentTarget: HTMLTextAreaElement }} event */
function handleInput(event) {
const value = event.currentTarget.value;
if (activeLang === 'ko') {
textKo = value;
} else {
textEn = value;
}
}
/** @param {'ko' | 'en'} lang */
function setLanguage(lang) {
activeLang = lang;
}
async function handleCopy() {
if (!hasMessage) return;
if (!activeText.trim()) return;
try {
await navigator.clipboard.writeText(plainText);
await navigator.clipboard.writeText(activeText);
copied = true;
setTimeout(() => {
copied = false;
@@ -25,29 +54,50 @@
}
</script>
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
{#if hasMessage}
<p class="min-w-0 flex-1 text-sm leading-relaxed text-muted">
{#each segments as segment, index (index)}
{#if segment.highlight}
<span class="text-pill">{'{'}</span><span class="font-medium text-ink"
>{segment.text}</span
><span class="text-pill">{'}'}</span>
{:else}
{segment.text}
{/if}
{/each}
</p>
<textarea
class="min-h-[5.5rem] min-w-0 flex-1 resize-y rounded border border-line bg-transparent px-3 py-2 text-sm leading-relaxed text-muted focus:border-line-strong focus:outline-none"
rows={4}
value={activeText}
oninput={handleInput}
aria-label={activeLang === 'ko' ? '꽃집 주문 멘트 (한국어)' : 'Florist order message (English)'}
></textarea>
{:else}
<p class="text-sm text-muted">Complete the flow to generate your order message.</p>
<p class="min-w-0 flex-1 text-sm text-muted">Complete the flow to generate your order message.</p>
{/if}
<button
type="button"
disabled={!hasMessage}
onclick={handleCopy}
class="shrink-0 rounded bg-pill px-3 py-1.5 text-xs text-surface disabled:opacity-40"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<div class="flex shrink-0 flex-col items-stretch gap-2">
<button
type="button"
disabled={!hasMessage}
onclick={handleCopy}
class="rounded bg-pill px-3 py-1.5 text-xs text-surface disabled:opacity-40"
>
{copied ? 'Copied!' : 'Copy'}
</button>
<div class="flex gap-1">
<button
type="button"
disabled={!hasMessage}
onclick={() => setLanguage('ko')}
class="flex-1 rounded border px-2 py-1.5 text-xs disabled:opacity-40 {activeLang === 'ko'
? 'border-pill bg-pill text-surface'
: 'border-line text-muted hover:border-line-strong'}"
>
Kor
</button>
<button
type="button"
disabled={!hasMessage}
onclick={() => setLanguage('en')}
class="flex-1 rounded border px-2 py-1.5 text-xs disabled:opacity-40 {activeLang === 'en'
? 'border-pill bg-pill text-surface'
: 'border-line text-muted hover:border-line-strong'}"
>
Eng
</button>
</div>
</div>
</div>

View File

@@ -11,7 +11,7 @@
mock = false,
fitBounds = false,
orderPlainText = '',
orderSegments = [],
orderKoPlainText = '',
onrefresh
} = $props();
@@ -53,7 +53,7 @@
</header>
<div class="shrink-0 px-6 pb-4 md:px-10 lg:px-12">
<FloristOrderMessage plainText={orderPlainText} segments={orderSegments} />
<FloristOrderMessage enPlainText={orderPlainText} koPlainText={orderKoPlainText} />
</div>
{#if error}

View File

@@ -4,7 +4,7 @@
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
let { primaryFile = $bindable(null), caption = 'build their moodboard!' } = $props();
let { primaryFile = $bindable(null), caption = 'build their moodboard!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props();
let colorFile = $state(null);
let seasonFile = $state(null);
@@ -16,6 +16,12 @@
if (primaryFile !== next) primaryFile = next;
});
$effect(() => {
const count = [colorFile, seasonFile, characterFile, locationFile].filter(Boolean).length;
filledCount = count;
allFilled = count === 4;
});
onMount(async () => {
const devUpload = getFlowObject('devUpload');
if (!isDevSeeded() || !devUpload?.active) return;

View File

@@ -4,7 +4,7 @@
import { hydrateDevUpload } from '$lib/dev/hydrateUpload.js';
import { getFlowObject, isDevSeeded } from '$lib/flowerFlow/session.js';
let { primaryFile = $bindable(null), caption = 'upload their feed!' } = $props();
let { primaryFile = $bindable(null), caption = 'upload their feed!', filledCount = $bindable(0), allFilled = $bindable(false) } = $props();
let firstFile = $state(null);
@@ -13,6 +13,11 @@
if (primaryFile !== next) primaryFile = next;
});
$effect(() => {
filledCount = firstFile ? 1 : 0;
allFilled = Boolean(firstFile);
});
onMount(async () => {
const devUpload = getFlowObject('devUpload');
if (!isDevSeeded() || !devUpload?.active) return;

View File

@@ -4,7 +4,11 @@
/** @typedef {{ text: string, highlight: boolean }} OrderMessageSegment */
/** @typedef {{ plainText: string, segments: OrderMessageSegment[] }} FloristOrderMessageResult */
/** @typedef {{ plainText: string, segments: OrderMessageSegment[] }} OrderMessageLocale */
/** @typedef {OrderMessageLocale & { ko: OrderMessageLocale }} FloristOrderMessageResult */
const EMPTY_LOCALE = /** @type {OrderMessageLocale} */ ({ plainText: '', segments: [] });
/**
* @param {string[]} [items]
@@ -26,7 +30,7 @@ export function buildFloristOrderMessage(input) {
const { userInput, moodAnalysis, recipe } = input;
if (!recipe && !userInput?.relationship && !userInput?.occasion) {
return { plainText: '', segments: [] };
return { ...EMPTY_LOCALE, ko: { ...EMPTY_LOCALE } };
}
const relationship = userInput?.relationship ?? 'someone special';
@@ -69,5 +73,29 @@ export function buildFloristOrderMessage(input) {
{ text: ' tones. Would a reservation be possible?', highlight: false }
];
return { plainText, segments };
const koPlainText =
`안녕하세요, 꽃 주문 문의드립니다. ` +
`${relationship}에게 ${occasion} 꽃다발을 준비하고 싶습니다. 예산은 약 ${budget}입니다. ` +
`${moodFeel}한 분위기로, ${colorTone} 톤으로 선물하고 싶습니다. ` +
`예약 가능할까요?`;
const koSegments = [
{ text: '안녕하세요, 꽃 주문 문의드립니다. ', highlight: false },
{ text: relationship, highlight: true },
{ text: '에게 ', highlight: false },
{ text: occasion, highlight: true },
{ text: ' 꽃다발을 준비하고 싶습니다. 예산은 약 ', highlight: false },
{ text: budget, highlight: true },
{ text: '입니다. ', highlight: false },
{ text: moodFeel, highlight: true },
{ text: '한 분위기로, ', highlight: false },
{ text: colorTone, highlight: true },
{ text: ' 톤으로 선물하고 싶습니다. 예약 가능할까요?', highlight: false }
];
return {
plainText,
segments,
ko: { plainText: koPlainText, segments: koSegments }
};
}

View File

@@ -0,0 +1,44 @@
import { GENERATING_ARTWORK_CYCLE } from '$lib/components/ui/Artwork/artworkVariants.js';
const FRAME_MS = 700;
/**
* generating 페이지 artwork 순환 (create2 → … → generated)
* @param {(variant: import('$lib/components/ui/Artwork/artworkVariants.js').ArtworkVariant) => void} onVariantChange
*/
export function createGeneratingArtworkCycle(onVariantChange) {
let frameIndex = 0;
/** @type {ReturnType<typeof setInterval> | null} */
let timer = null;
let disposed = false;
function emitCurrent() {
onVariantChange(GENERATING_ARTWORK_CYCLE[frameIndex]);
}
function start() {
stop();
disposed = false;
frameIndex = 0;
emitCurrent();
timer = setInterval(() => {
if (disposed) return;
frameIndex = (frameIndex + 1) % GENERATING_ARTWORK_CYCLE.length;
emitCurrent();
}, FRAME_MS);
}
function stop() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
function dispose() {
disposed = true;
stop();
}
return { start, stop, dispose };
}

View File

@@ -0,0 +1,127 @@
import { GENERATION_STEP_COUNT } from '$lib/components/ui/generating/generationSteps.js';
/** 실제 API 기준 예상 총 소요 시간 (ms) — 7단계 균등 분배 */
export const DEFAULT_ESTIMATED_MS = 40_000;
/** mock/dev: 7단계가 눈에 보이도록 짧게 */
export const MOCK_ESTIMATED_MS = 6_000;
/** stepInterval 하한 */
export const MIN_STEP_MS = 500;
/** API 조기 완료 시 남은 단계 catch-up 간격 */
export const CATCHUP_STEP_MS = 250;
/** @param {number} ms */
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* generating UI 7단계 시간 기반 진행 컨트롤러.
* activeStepIndex: 06 = 해당 단계 active, GENERATION_STEP_COUNT = 전부 완료.
*
* @param {(index: number) => void} onStepChange
*/
export function createGenerationProgress(onStepChange) {
/** @type {ReturnType<typeof setTimeout> | null} */
let stepTimer = null;
let disposed = false;
let currentIndex = 0;
let capped = false;
function emit(index) {
currentIndex = index;
onStepChange(index);
}
function clearStepTimer() {
if (stepTimer) {
clearTimeout(stepTimer);
stepTimer = null;
}
}
function dispose() {
disposed = true;
clearStepTimer();
}
function reset() {
clearStepTimer();
disposed = false;
capped = false;
currentIndex = 0;
emit(0);
}
/**
* @param {number} estimatedMs
* @returns {number}
*/
function stepIntervalMs(estimatedMs) {
return Math.max(MIN_STEP_MS, Math.floor(estimatedMs / GENERATION_STEP_COUNT));
}
/**
* @param {number} estimatedMs
*/
function scheduleNextStep(estimatedMs) {
clearStepTimer();
if (disposed || capped) return;
stepTimer = setTimeout(() => {
if (disposed || capped) return;
if (currentIndex < GENERATION_STEP_COUNT - 1) {
emit(currentIndex + 1);
if (currentIndex >= GENERATION_STEP_COUNT - 1) {
capped = true;
return;
}
scheduleNextStep(estimatedMs);
}
}, stepIntervalMs(estimatedMs));
}
/**
* @param {{ estimatedMs?: number }} [options]
*/
function begin(options = {}) {
const estimatedMs = options.estimatedMs ?? DEFAULT_ESTIMATED_MS;
reset();
emit(0);
scheduleNextStep(estimatedMs);
}
/** 전 단계 완료 */
function completeAll() {
clearStepTimer();
capped = true;
emit(GENERATION_STEP_COUNT);
}
/** API 완료 시 — 남은 단계 catch-up 후 completeAll */
async function finishWhenReady() {
clearStepTimer();
while (!disposed && currentIndex < GENERATION_STEP_COUNT - 1) {
emit(currentIndex + 1);
await wait(CATCHUP_STEP_MS);
}
if (!disposed) {
completeAll();
}
}
return {
begin,
completeAll,
finishWhenReady,
reset,
dispose
};
}

View File

@@ -27,6 +27,8 @@
return `${occasion} ${who ?? '...'}`;
});
const artworkVariant = $derived(hasAnySelection ? 'create2' : 'create1');
const artworkDescription = $derived(
hasAnySelection
? `${style ?? '—'} style · ₩${budget.toLocaleString('ko-KR')} budget`
@@ -82,7 +84,7 @@
<Header step={1} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork title={artworkTitle} description={artworkDescription} />
<Artwork variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<ContextForm bind:who bind:whatFor bind:style bind:budget />

View File

@@ -3,25 +3,62 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import Header from '$lib/components/ui/Header.svelte';
import Artwork from '$lib/components/ui/Artwork/Artwork.svelte';
import GenerationActivityFeed from '$lib/components/ui/generating/GenerationActivityFeed.svelte';
import { buildRecipe, generateImages } from '$lib/flowerFlow/api.js';
import { clearFlow, getFlowObject, loadFlow, saveFlow } from '$lib/flowerFlow/session.js';
import { createGenerationProgress, DEFAULT_ESTIMATED_MS, MOCK_ESTIMATED_MS } from '$lib/flowerFlow/generationProgress.js';
import { createGeneratingArtworkCycle } from '$lib/flowerFlow/generatingArtworkCycle.js';
import {
clearFlow,
getFlowObject,
getFlowString,
getFlowUserInput,
loadFlow,
saveFlow
} from '$lib/flowerFlow/session.js';
const MAX_RETRIES = 5;
const userInput = getFlowUserInput();
const cardMessage = getFlowString('cardMessage');
let status = $state('Preparing bouquet recipe...');
const artworkTitle = $derived.by(() => {
const who = typeof userInput.relationship === 'string' ? userInput.relationship : null;
const whatFor = typeof userInput.occasion === 'string' ? userInput.occasion : null;
if (!who && !whatFor) return 'Your bouquet';
const occasion = whatFor ? `A ${whatFor} bouquet for` : 'A bouquet for';
return `${occasion} ${who ?? '...'}`;
});
const artworkDescription = $derived(cardMessage || '잠시 관리중 ~');
/** @type {import('$lib/components/ui/Artwork/artworkVariants.js').ArtworkVariant} */
let artworkVariant = $state('create2');
let activeStepIndex = $state(0);
let retryLabel = $state('');
let error = $state('');
let canRetry = $state(false);
let active = true;
/** @type {ReturnType<typeof createGenerationProgress> | null} */
let progress = null;
/** @type {ReturnType<typeof createGeneratingArtworkCycle> | null} */
let artworkCycle = null;
function startArtworkCycle() {
artworkCycle?.dispose();
artworkCycle = createGeneratingArtworkCycle((variant) => {
artworkVariant = variant;
});
artworkCycle.start();
}
/** @param {number} ms */
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolveWait) => setTimeout(resolveWait, ms));
}
/**
* Read the structured fields the server now sends. Falls back to message
* sniffing only if an older/unstructured error slips through.
* @param {any} err
*/
function classify(err) {
@@ -42,9 +79,6 @@
}
/**
* Run a task with a finite, classified retry policy: permanent errors stop
* immediately, transient ones retry up to MAX_RETRIES respecting the
* server-provided delay, and the real error is surfaced either way.
* @template T
* @param {string} label
* @param {() => Promise<T>} task
@@ -55,8 +89,8 @@
while (active) {
try {
status =
attempt === 0 ? label : `Retrying ${label.toLowerCase()} (${attempt}/${MAX_RETRIES})...`;
retryLabel =
attempt === 0 ? '' : `Retrying ${label.toLowerCase()} (${attempt}/${MAX_RETRIES})`;
error = '';
return await task();
} catch (err) {
@@ -68,7 +102,7 @@
attempt += 1;
const seconds = Math.round(retryAfterMs / 1000);
status = `AI provider is busy. Retrying in ${seconds}s (${attempt}/${MAX_RETRIES})...`;
retryLabel = `AI provider is busy. Retrying in ${seconds}s (${attempt}/${MAX_RETRIES})`;
await wait(retryAfterMs);
}
}
@@ -77,10 +111,15 @@
}
async function runGeneration() {
if (!progress) return;
canRetry = false;
error = '';
retryLabel = '';
const flow = loadFlow();
const jobId = typeof flow.jobId === 'string' ? flow.jobId : '';
const userInput = getFlowObject('userInput') ?? {};
const sessionUserInput = getFlowObject('userInput') ?? {};
if (!jobId) {
await goto(resolve('/create'));
@@ -88,21 +127,27 @@
}
try {
const estimatedMs = flow.mock ? MOCK_ESTIMATED_MS : DEFAULT_ESTIMATED_MS;
progress.begin({ estimatedMs });
const existingRecipe = getFlowObject('recipe');
if (!existingRecipe) {
const recipeResult = await runWithRetry('Building bouquet recipe...', () =>
buildRecipe(jobId, userInput)
const recipeResult = await runWithRetry('Building bouquet recipe', () =>
buildRecipe(jobId, sessionUserInput)
);
saveFlow({ recipe: recipeResult.recipe });
}
const imageResult = await runWithRetry('Generating bouquet image...', () =>
const imageResult = await runWithRetry('Generating bouquet image', () =>
generateImages(jobId)
);
// Do NOT persist the multi-MB base64 images in sessionStorage — Safari caps
// it at ~5MB and throws "QuotaExceededError: The quota has been exceeded."
// The images already live in Supabase Storage via the job; the options
// The images already live in Supabase Storage via the job; the edit
// and result pages fetch them by jobId. We only keep lightweight metadata here.
await progress.finishWhenReady();
saveFlow({
imagesJobId: jobId,
imagePrompt: imageResult.imagePrompt,
@@ -119,61 +164,65 @@
const stale =
code === 'job_not_found' || (err && typeof err === 'object' && err.status === 404);
if (stale) {
// Keep the user's entered context (relationship/occasion/etc.), drop the
// dead job, and re-upload to mint a fresh one.
const userInput = getFlowObject('userInput');
const preservedInput = getFlowObject('userInput');
clearFlow();
if (userInput) saveFlow({ userInput });
error = '';
status = 'This session expired. Starting over...';
if (preservedInput) saveFlow({ userInput: preservedInput });
retryLabel = '';
await goto(resolve('/upload'));
return;
}
const { permanent } = classify(err);
error = err instanceof Error ? err.message : 'Generation failed';
status = permanent ? 'Generation is blocked.' : 'Still failing after several retries.';
retryLabel = permanent ? 'Generation is blocked.' : 'Still failing after several retries.';
canRetry = true;
progress?.reset();
}
}
function retry() {
if (!active) return;
startArtworkCycle();
runGeneration();
}
function backToMessage() {
goto(resolve('/message'));
}
onMount(() => {
active = true;
progress = createGenerationProgress((index) => {
activeStepIndex = index;
});
startArtworkCycle();
runGeneration();
return () => {
active = false;
progress?.dispose();
artworkCycle?.dispose();
};
});
</script>
<div class="min-h-dvh bg-surface text-ink">
<div
class="flex h-dvh flex-col overflow-x-hidden bg-surface text-ink lg:h-screen lg:overflow-hidden"
>
<Header step={4} total={7} />
<main class="mx-auto flex max-w-xl flex-col items-start px-6 py-16">
<h1 class="mb-3 text-2xl">Generating</h1>
<p class="text-sm text-muted">{status}</p>
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork comingSoon variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
{#if error}
<p class="mt-6 text-sm text-red-600">{error}</p>
<div class="mt-4 flex gap-3">
{#if canRetry}
<button type="button" class="bg-pill px-4 py-2 text-sm text-surface" onclick={retry}>
Try again
</button>
{/if}
<button
type="button"
class="border border-pill px-4 py-2 text-sm text-ink"
onclick={() => goto(resolve('/message'))}
>
Back to message
</button>
</div>
{/if}
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<GenerationActivityFeed
{activeStepIndex}
{error}
{retryLabel}
{canRetry}
onRetry={retry}
onBack={backToMessage}
/>
</section>
</main>
</div>

View File

@@ -22,7 +22,7 @@
let floristNote = $state('');
let fitMapBounds = $state(true);
let orderPlainText = $state('');
let orderSegments = $state([]);
let orderKoPlainText = $state('');
let selectedImage = $state(null);
const sessionUserInput = getFlowObject('userInput') ?? {};
@@ -83,7 +83,7 @@
recipe: job.recipe
});
orderPlainText = order.plainText;
orderSegments = order.segments;
orderKoPlainText = order.ko.plainText;
} catch {
// job 없어도 지도·꽃집 검색은 계속
}
@@ -108,7 +108,7 @@
{error}
{mock}
{orderPlainText}
{orderSegments}
{orderKoPlainText}
fitBounds={fitMapBounds}
onrefresh={(lat, lng) => loadShops(lat, lng, { fitBounds: false })}
/>

View File

@@ -24,6 +24,8 @@
let error = $state('');
let skipping = $state(false);
const artworkVariant = $derived(message.trim() ? 'message1' : 'upload2');
const artworkTitle = $derived(message ? 'Your message' : 'Title');
const artworkDescription = $derived(message || 'Description Description Description');
@@ -102,7 +104,7 @@
<Header step={3} total={7} />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork title={artworkTitle} description={artworkDescription} />
<Artwork variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<section class="relative flex min-h-0 flex-1 flex-col lg:overflow-y-auto">
<MessageForm bind:message />

View File

@@ -24,6 +24,8 @@
: 'moodboard'
);
let primaryFile = $state(null);
let filledCount = $state(0);
let allFilled = $state(false);
let loading = $state(false);
let error = $state('');
@@ -34,6 +36,37 @@
return 'their';
});
const hasUserContext = $derived(
Boolean(userInput.relationship || userInput.occasion || userInput.style)
);
const artworkTitle = $derived.by(() => {
const who = userInput.relationship;
const whatFor = userInput.occasion;
if (!hasUserContext) return 'Title';
const occasion = whatFor ? `A ${whatFor} bouquet for` : 'A bouquet for';
return `${occasion} ${who ?? '...'}`;
});
const artworkDescription = $derived(
hasUserContext
? `${userInput.style ?? '—'} style · ₩${Number(userInput.budget ?? 50_000).toLocaleString('ko-KR')} budget`
: 'Description Description Description'
);
/** create2(시작) → upload1(1장+) → upload2(전체 채움) */
const artworkVariant = $derived.by(() => {
if (allFilled) return 'upload2';
if (filledCount > 0) return 'upload1';
return 'create2';
});
$effect(() => {
void mode;
filledCount = 0;
allFilled = false;
});
async function continueToMessage() {
error = '';
@@ -84,16 +117,26 @@
>
<Header step={2} total={7} />
<main class="flex min-h-0 flex-1 flex-col pt-6 lg:flex-row lg:pt-8">
<Artwork />
<main class="flex min-h-0 flex-1 flex-col lg:flex-row">
<Artwork variant={artworkVariant} title={artworkTitle} description={artworkDescription} />
<section
class="relative flex min-h-0 flex-1 flex-col pb-[4.75rem] lg:grid lg:grid-rows-[minmax(0,1fr)_auto] lg:overflow-hidden lg:pb-8"
class="relative flex min-h-0 flex-1 flex-col pt-6 pb-[4.75rem] lg:grid lg:grid-rows-[minmax(0,1fr)_auto] lg:overflow-hidden lg:pt-8 lg:pb-8"
>
{#if mode === 'moodboard'}
<MoodboardGrid bind:primaryFile caption={`build ${recipientPronoun} moodboard!`} />
<MoodboardGrid
bind:primaryFile
bind:filledCount
bind:allFilled
caption={`build ${recipientPronoun} moodboard!`}
/>
{:else}
<SnsFeedUpload bind:primaryFile caption={`upload ${recipientPronoun} feed!`} />
<SnsFeedUpload
bind:primaryFile
bind:filledCount
bind:allFilled
caption={`upload ${recipientPronoun} feed!`}
/>
{/if}
<div