feat(legal): privacy/terms/contact + themed 404/500 — 8.2

This commit is contained in:
reloop
2026-04-12 08:23:16 +09:00
parent 7ba7b4afd2
commit eb0e07bab9
8 changed files with 1082 additions and 8 deletions

View File

@@ -0,0 +1,253 @@
import type { Metadata } from 'next';
import styled from 'styled-components';
import LegalLayout from '@/components/legal/LegalLayout';
import { theme } from '@/styles/theme';
export const metadata: Metadata = {
title: '문의하기',
description: 'ReLoop 문의 채널 안내',
};
const lastUpdated = '2026-04-12';
export default function ContactPage() {
return (
<LegalLayout lastUpdated={lastUpdated}>
<Header>
<Title></Title>
<Lead>
, , .
3 .
</Lead>
</Header>
<Grid>
<InfoCard>
<CardTitle> </CardTitle>
<InfoList>
<InfoItem>
<InfoLabel></InfoLabel>
<InfoValue href="mailto:support@reloop.nabomhalang.co.kr">
support@reloop.nabomhalang.co.kr
</InfoValue>
</InfoItem>
<InfoItem>
<InfoLabel> </InfoLabel>
<InfoText>@reloop_support</InfoText>
</InfoItem>
<InfoItem>
<InfoLabel> </InfoLabel>
<InfoText> 3 </InfoText>
</InfoItem>
</InfoList>
</InfoCard>
<FormCard>
<CardTitle> </CardTitle>
<Form
action="mailto:support@reloop.nabomhalang.co.kr"
method="post"
encType="text/plain"
>
<Field>
<StyledLabel htmlFor="contact-name"></StyledLabel>
<StyledInput
id="contact-name"
name="name"
placeholder="이름을 입력해 주세요"
data-selectable="true"
/>
</Field>
<Field>
<StyledLabel htmlFor="contact-email"></StyledLabel>
<StyledInput
id="contact-email"
type="email"
name="email"
placeholder="답변받을 이메일"
data-selectable="true"
/>
</Field>
<Field>
<StyledLabel htmlFor="contact-message"></StyledLabel>
<StyledTextarea
id="contact-message"
name="message"
placeholder="문의 내용을 적어 주세요"
rows={6}
data-selectable="true"
/>
</Field>
<SubmitButton type="submit"> </SubmitButton>
</Form>
<FormHint>
.
.
</FormHint>
</FormCard>
</Grid>
</LegalLayout>
);
}
const Header = styled.header`
margin-bottom: 2rem;
`;
const Title = styled.h1`
margin: 0 0 0.75rem;
color: ${theme.color.textBright};
font-size: 1.75rem;
font-weight: 600;
line-height: 1.3;
`;
const Lead = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 0.9375rem;
line-height: 1.7;
`;
const Grid = styled.div`
display: grid;
gap: 1.25rem;
`;
const BaseCard = styled.section`
padding: 1.5rem;
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 1.25rem;
background: rgba(255, 255, 255, 0.03);
`;
const InfoCard = styled(BaseCard)``;
const FormCard = styled(BaseCard)``;
const CardTitle = styled.h2`
margin: 0 0 1rem;
color: ${theme.color.textBright};
font-size: 1.125rem;
font-weight: 600;
`;
const InfoList = styled.div`
display: grid;
gap: 1rem;
`;
const InfoItem = styled.div`
display: grid;
gap: 0.25rem;
`;
const InfoLabel = styled.p`
margin: 0;
color: ${theme.color.textMute};
font-size: 0.8125rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const InfoText = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 0.9375rem;
line-height: 1.7;
`;
const InfoValue = styled.a`
color: ${theme.color.textBright};
font-size: 0.9375rem;
line-height: 1.7;
word-break: break-word;
`;
const Form = styled.form`
display: grid;
gap: 1rem;
`;
const Field = styled.div`
display: grid;
gap: 0.4rem;
`;
const StyledLabel = styled.label`
color: ${theme.color.textSub};
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
`;
const StyledInput = styled.input`
width: 100%;
min-height: 3rem;
padding: 0.75rem 0.875rem;
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.md};
background: rgba(28, 35, 64, 0.92);
color: ${theme.color.textMain};
font-size: 1rem;
&::placeholder {
color: ${theme.color.textMute};
}
&:focus {
outline: none;
border-color: ${theme.color.accent};
}
`;
const StyledTextarea = styled.textarea`
width: 100%;
min-height: 9rem;
padding: 0.875rem;
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.md};
background: rgba(28, 35, 64, 0.92);
color: ${theme.color.textMain};
font-size: 1rem;
font-family: inherit;
resize: vertical;
&::placeholder {
color: ${theme.color.textMute};
}
&:focus {
outline: none;
border-color: ${theme.color.accent};
}
`;
const SubmitButton = styled.button`
min-height: 3rem;
padding: 0.875rem 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: ${theme.radius.md};
background: ${theme.color.brandGradient};
color: white;
font-size: 0.95rem;
font-weight: 600;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: translateY(-1px);
box-shadow: ${theme.shadow.glowIndigoStrong};
}
`;
const FormHint = styled.p`
margin: 0.875rem 0 0;
color: ${theme.color.textMute};
font-size: 0.8125rem;
line-height: 1.6;
`;

107
frontend/src/app/error.tsx Normal file
View File

@@ -0,0 +1,107 @@
'use client';
import Link from 'next/link';
import styled from 'styled-components';
import { theme } from '@/styles/theme';
type ErrorPageProps = {
error: Error & { digest?: string };
reset: () => void;
};
export default function ErrorPage({ error, reset }: ErrorPageProps) {
return (
<Shell>
<Card>
<Title> </Title>
<Description> . .</Description>
<Actions>
<PrimaryButton type="button" onClick={() => reset()}>
</PrimaryButton>
<SecondaryLink href="/"></SecondaryLink>
</Actions>
{error.digest ? <Digest>{error.digest}</Digest> : null}
</Card>
</Shell>
);
}
const Shell = styled.main`
min-height: 100vh;
display: grid;
place-items: center;
padding: 2rem;
background:
radial-gradient(circle at top, rgba(79, 70, 229, 0.12), transparent 30%),
${theme.color.bgDeep};
color: ${theme.color.textBright};
`;
const Card = styled.section`
width: min(100%, 32rem);
padding: 2.25rem;
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 1.5rem;
background: rgba(21, 21, 28, 0.92);
box-shadow: ${theme.shadow.cardElevated};
text-align: center;
`;
const Title = styled.h1`
margin: 0;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.25;
`;
const Description = styled.p`
margin: 0.75rem 0 0;
color: ${theme.color.textSub};
font-size: 0.975rem;
line-height: 1.7;
`;
const Actions = styled.div`
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.5rem;
`;
const PrimaryButton = styled.button`
min-height: 3rem;
padding: 0.875rem 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: ${theme.radius.md};
background: ${theme.color.brandGradient};
color: white;
font-size: 0.95rem;
font-weight: 600;
`;
const SecondaryLink = styled(Link)`
min-height: 3rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.875rem 1.25rem;
border: 1px solid ${theme.color.border};
border-radius: ${theme.radius.md};
background: rgba(255, 255, 255, 0.04);
color: ${theme.color.textBright};
font-size: 0.95rem;
font-weight: 600;
`;
const Digest = styled.code`
display: inline-block;
margin-top: 1rem;
color: ${theme.color.textMute};
font-size: 0.75rem;
font-family: ${theme.font.mono};
word-break: break-all;
`;

View File

@@ -0,0 +1,81 @@
'use client';
type GlobalErrorPageProps = {
error: Error & { digest?: string };
};
export default function GlobalErrorPage({ error }: GlobalErrorPageProps) {
return (
<html lang="ko">
<body
style={{
margin: 0,
minHeight: '100vh',
display: 'grid',
placeItems: 'center',
padding: '32px',
background:
'radial-gradient(circle at top, rgba(79, 70, 229, 0.16), transparent 34%), #0F0F14',
color: '#F8FAFC',
fontFamily:
"'Pretendard Variable', 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
}}
>
<main
style={{
width: 'min(100%, 520px)',
padding: '32px',
border: '1px solid rgba(255, 255, 255, 0.08)',
borderRadius: '24px',
background: 'rgba(21, 21, 28, 0.92)',
textAlign: 'center',
}}
>
<h1 style={{ margin: 0, fontSize: '32px', lineHeight: 1.2 }}> </h1>
<p
style={{
margin: '12px 0 0',
color: '#94A3B8',
fontSize: '16px',
lineHeight: 1.7,
}}
>
Something went wrong
</p>
{error.digest ? (
<p
style={{
margin: '14px 0 0',
color: '#64748B',
fontFamily:
"var(--font-jetbrains-mono), 'JetBrains Mono', ui-monospace, monospace",
fontSize: '12px',
wordBreak: 'break-all',
}}
>
{error.digest}
</p>
) : null}
<a
href="/"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '48px',
marginTop: '24px',
padding: '0 20px',
borderRadius: '12px',
background: 'linear-gradient(135deg, #4F46E5 0%, #7C3AED 100%)',
color: '#FFFFFF',
textDecoration: 'none',
fontWeight: 600,
}}
>
</a>
</main>
</body>
</html>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import styled, { css } from 'styled-components';
import { animations, theme } from '@/styles/theme';
export default function NotFound() {
const router = useRouter();
return (
<Shell>
<GlowOne aria-hidden="true" />
<GlowTwo aria-hidden="true" />
<Content>
<Code>404</Code>
<Title> </Title>
<Description>URL을 </Description>
<Actions>
<SecondaryButton type="button" onClick={() => router.back()}>
</SecondaryButton>
<PrimaryLink href="/"></PrimaryLink>
</Actions>
</Content>
</Shell>
);
}
const Shell = styled.main`
position: relative;
min-height: 100vh;
display: grid;
place-items: center;
overflow: hidden;
padding: 2rem;
background:
radial-gradient(circle at top, rgba(79, 70, 229, 0.14), transparent 35%),
${theme.color.bgDeep};
color: ${theme.color.textBright};
`;
const Glow = styled.div`
position: absolute;
width: 26rem;
height: 26rem;
border-radius: 50%;
filter: blur(90px);
opacity: 0.65;
`;
const GlowOne = styled(Glow)`
top: -6rem;
left: -8rem;
background: rgba(79, 70, 229, 0.3);
animation: ${animations.float} 8s ease-in-out infinite;
`;
const GlowTwo = styled(Glow)`
right: -8rem;
bottom: -10rem;
background: rgba(124, 58, 237, 0.2);
animation: ${animations.float} 10s ease-in-out infinite reverse;
`;
const Content = styled.section`
position: relative;
z-index: 1;
width: min(100%, 34rem);
padding: 2.5rem;
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 1.75rem;
background: rgba(21, 21, 28, 0.9);
box-shadow: ${theme.shadow.cardElevated};
text-align: center;
`;
const Code = styled.p`
margin: 0 0 0.75rem;
font-family: ${theme.font.display};
font-size: clamp(4.5rem, 18vw, 7rem);
font-weight: 800;
line-height: 0.95;
letter-spacing: -0.06em;
background: ${theme.color.brandGradient};
-webkit-background-clip: text;
background-clip: text;
color: transparent;
`;
const Title = styled.h1`
margin: 0;
font-size: 1.75rem;
font-weight: 700;
line-height: 1.25;
`;
const Description = styled.p`
margin: 0.75rem 0 0;
color: ${theme.color.textSub};
font-size: 0.975rem;
line-height: 1.7;
`;
const Actions = styled.div`
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.75rem;
`;
const actionStyles = css`
min-height: 3rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.875rem 1.25rem;
border-radius: ${theme.radius.md};
font-size: 0.95rem;
font-weight: 600;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
&:hover {
transform: translateY(-1px);
}
`;
const SecondaryButton = styled.button`
${actionStyles}
border: 1px solid ${theme.color.border};
background: rgba(255, 255, 255, 0.04);
color: ${theme.color.textBright};
`;
const PrimaryLink = styled(Link)`
${actionStyles}
border: 1px solid rgba(255, 255, 255, 0.08);
background: ${theme.color.brandGradient};
color: white;
`;

View File

@@ -460,9 +460,9 @@ export default function LandingPage() {
</FooterColumn>
<FooterColumn>
<FooterHeading></FooterHeading>
<FooterText></FooterText>
<FooterText></FooterText>
<FooterText>support@reloop.co</FooterText>
<FooterAnchor href="/terms"></FooterAnchor>
<FooterAnchor href="/privacy"></FooterAnchor>
<FooterAnchor href="/contact"></FooterAnchor>
</FooterColumn>
</FooterLinks>
</FooterInner>
@@ -1435,11 +1435,6 @@ const FooterAnchor = styled.a`
}
`;
const FooterText = styled.p`
margin: 0;
color: ${theme.color.textSub};
`;
const FooterBottom = styled.div`
${widthClamp}
padding-top: 2rem;

View File

@@ -0,0 +1,111 @@
import type { Metadata } from 'next';
import styled from 'styled-components';
import LegalLayout from '@/components/legal/LegalLayout';
import { theme } from '@/styles/theme';
export const metadata: Metadata = {
title: '개인정보처리방침',
description: 'ReLoop 개인정보처리방침 안내',
};
const lastUpdated = '2026-04-12';
export default function PrivacyPage() {
return (
<LegalLayout lastUpdated={lastUpdated}>
<Header>
<Title></Title>
</Header>
<Section>
<SectionTitle>1. </SectionTitle>
<BodyText>
ReLoop v2는 , , (),
, .
</BodyText>
</Section>
<Section>
<SectionTitle>2. </SectionTitle>
<BodyText>
, , , ,
.
</BodyText>
</Section>
<Section>
<SectionTitle>3. </SectionTitle>
<BodyText>
,
30 .
</BodyText>
</Section>
<Section>
<SectionTitle>4. 3 </SectionTitle>
<BodyText>
ReLoop v2는 3 .
.
</BodyText>
</Section>
<Section>
<SectionTitle>5. </SectionTitle>
<BodyText>
JWT ,
.
</BodyText>
</Section>
<Section>
<SectionTitle>6. </SectionTitle>
<BodyText>
, , ,
.
</BodyText>
</Section>
<Section>
<SectionTitle>7. </SectionTitle>
<BodyText>
.
<br />
이메일: support@reloop.nabomhalang.co.kr
</BodyText>
</Section>
</LegalLayout>
);
}
const Header = styled.header`
margin-bottom: 2rem;
`;
const Title = styled.h1`
margin: 0;
color: ${theme.color.textBright};
font-size: 1.75rem;
font-weight: 600;
line-height: 1.3;
`;
const Section = styled.section`
& + & {
margin-top: 2rem;
}
`;
const SectionTitle = styled.h2`
margin: 0 0 0.75rem;
color: ${theme.color.textBright};
font-size: 1.125rem;
font-weight: 600;
line-height: 1.4;
`;
const BodyText = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 0.9375rem;
line-height: 1.7;
`;

View File

@@ -0,0 +1,127 @@
import type { Metadata } from 'next';
import styled from 'styled-components';
import LegalLayout from '@/components/legal/LegalLayout';
import { theme } from '@/styles/theme';
export const metadata: Metadata = {
title: '이용약관',
description: 'ReLoop 이용약관 안내',
};
const lastUpdated = '2026-04-12';
export default function TermsPage() {
return (
<LegalLayout lastUpdated={lastUpdated}>
<Header>
<Title></Title>
</Header>
<Section>
<SectionTitle>1. </SectionTitle>
<BodyText>
ReLoop v2 , ,
.
</BodyText>
</Section>
<Section>
<SectionTitle>2. </SectionTitle>
<BodyText>
. ReLoop v2에
,
, , .
,
.
</BodyText>
</Section>
<Section>
<SectionTitle>3. </SectionTitle>
<BodyText>
.
,
.
</BodyText>
</Section>
<Section>
<SectionTitle>4. </SectionTitle>
<BodyText>
ReLoop v2는 , ,
.
.
</BodyText>
</Section>
<Section>
<SectionTitle>5. </SectionTitle>
<BodyText>
,
.
,
.
</BodyText>
</Section>
<Section>
<SectionTitle>6. </SectionTitle>
<BodyText>
, ,
.
</BodyText>
</Section>
<Section>
<SectionTitle>7. </SectionTitle>
<BodyText>
, ,
.
.
</BodyText>
</Section>
<Section>
<SectionTitle>8. </SectionTitle>
<BodyText>
,
.
</BodyText>
</Section>
</LegalLayout>
);
}
const Header = styled.header`
margin-bottom: 2rem;
`;
const Title = styled.h1`
margin: 0;
color: ${theme.color.textBright};
font-size: 1.75rem;
font-weight: 600;
line-height: 1.3;
`;
const Section = styled.section`
& + & {
margin-top: 2rem;
}
`;
const SectionTitle = styled.h2`
margin: 0 0 0.75rem;
color: ${theme.color.textBright};
font-size: 1.125rem;
font-weight: 600;
line-height: 1.4;
`;
const BodyText = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 0.9375rem;
line-height: 1.7;
`;

View File

@@ -0,0 +1,258 @@
'use client';
import React from 'react';
import Link from 'next/link';
import styled from 'styled-components';
import { Icon } from '@/components/ui/Icon';
import { theme } from '@/styles/theme';
type LegalLayoutProps = {
children: React.ReactNode;
lastUpdated: string;
};
const legalLinks = [
{ href: '/', label: '홈' },
{ href: '/login', label: '로그인' },
{ href: '/pricing', label: '가격' },
{ href: '/contact', label: '문의' },
];
const footerLinks = [
{ href: '/privacy', label: '개인정보처리방침' },
{ href: '/terms', label: '이용약관' },
{ href: '/contact', label: '문의' },
];
export default function LegalLayout({ children, lastUpdated }: LegalLayoutProps) {
return (
<Shell>
<BackgroundLayer aria-hidden="true">
<TopGlow />
<BottomGlow />
</BackgroundLayer>
<TopBar>
<TopBarInner>
<BrandLink href="/" aria-label="ReLoop 홈">
<BrandMark>
<BrandGlow />
<Icon name="infinity" size={18} weight="bold" />
</BrandMark>
<BrandWordmark>ReLoop</BrandWordmark>
</BrandLink>
<Nav aria-label="법적 페이지 탐색">
{legalLinks.map((item) => (
<NavLink key={item.href} href={item.href}>
{item.label}
</NavLink>
))}
</Nav>
</TopBarInner>
</TopBar>
<Main>
<ContentCard>
<MetaText> {lastUpdated}</MetaText>
{children}
</ContentCard>
</Main>
<Footer>
<FooterInner>
<FooterCopy>© 2026 ReLoop. All rights reserved.</FooterCopy>
<FooterNav aria-label="법적 링크">
{footerLinks.map((item) => (
<FooterLink key={item.href} href={item.href}>
{item.label}
</FooterLink>
))}
</FooterNav>
</FooterInner>
</Footer>
</Shell>
);
}
const Shell = styled.div`
position: relative;
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(79, 70, 229, 0.14), transparent 34%),
${theme.color.bgDeep};
color: ${theme.color.textBright};
`;
const BackgroundLayer = styled.div`
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
`;
const Glow = styled.div`
position: absolute;
width: 26rem;
height: 26rem;
border-radius: 50%;
filter: blur(80px);
opacity: 0.6;
`;
const TopGlow = styled(Glow)`
top: -10rem;
left: -7rem;
background: rgba(79, 70, 229, 0.24);
`;
const BottomGlow = styled(Glow)`
right: -8rem;
bottom: -12rem;
background: rgba(124, 58, 237, 0.16);
`;
const TopBar = styled.header`
position: sticky;
top: 0;
z-index: 10;
border-bottom: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(11, 16, 32, 0.78);
backdrop-filter: blur(18px);
`;
const TopBarInner = styled.div`
width: min(100%, 72rem);
margin: 0 auto;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column;
align-items: flex-start;
}
`;
const BrandLink = styled(Link)`
display: inline-flex;
align-items: center;
gap: 0.75rem;
color: inherit;
text-decoration: none;
`;
const BrandMark = styled.span`
position: relative;
width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.75rem;
overflow: hidden;
background: ${theme.color.brandGradient};
box-shadow: ${theme.shadow.glowIndigo};
color: white;
`;
const BrandGlow = styled.span`
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 60%);
`;
const BrandWordmark = styled.span`
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.02em;
`;
const Nav = styled.nav`
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
`;
const NavLink = styled(Link)`
color: ${theme.color.textSub};
font-size: 0.9rem;
font-weight: 500;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: ${theme.color.textBright};
}
`;
const Main = styled.main`
position: relative;
z-index: 1;
padding: 3rem 1.5rem 4rem;
`;
const ContentCard = styled.div`
width: min(100%, 45rem);
margin: 0 auto;
padding: clamp(1.5rem, 4vw, 3rem);
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 1.5rem;
background: rgba(21, 21, 28, 0.88);
box-shadow: ${theme.shadow.cardElevated};
`;
const MetaText = styled.p`
margin: 0 0 1.25rem;
color: ${theme.color.textMute};
font-size: 0.875rem;
font-family: ${theme.font.mono};
`;
const Footer = styled.footer`
position: relative;
z-index: 1;
border-top: 1px solid ${theme.color.borderSoftAlpha};
background: rgba(0, 0, 0, 0.16);
`;
const FooterInner = styled.div`
width: min(100%, 72rem);
margin: 0 auto;
padding: 1.25rem 1.5rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
@media (max-width: ${theme.breakpoint.mobile}) {
flex-direction: column;
align-items: flex-start;
}
`;
const FooterCopy = styled.p`
margin: 0;
color: ${theme.color.textMute};
font-size: 0.875rem;
`;
const FooterNav = styled.nav`
display: flex;
gap: 1rem;
flex-wrap: wrap;
`;
const FooterLink = styled(Link)`
color: ${theme.color.textSub};
font-size: 0.875rem;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: ${theme.color.textBright};
}
`;