оферта

This commit is contained in:
2026-07-26 22:13:22 +04:00
parent 857032fa90
commit 9ed56ec93d
17 changed files with 1363 additions and 127 deletions
+3 -2
View File
@@ -59,10 +59,11 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
MOONWELL_LEGAL_PUBLISHED=false
MOONWELL_OFFER_PUBLISHED=true
MOONWELL_PRIVACY_PUBLISHED=false
MOONWELL_LEGAL_OPERATOR="Администрация проекта MoonWell"
MOONWELL_LEGAL_EMAIL="support@moon-well.online"
MOONWELL_LEGAL_EFFECTIVE_DATE="25 июля 2026 года"
MOONWELL_LEGAL_EFFECTIVE_DATE="26 июля 2026 года"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
@@ -29,8 +29,8 @@ class LoginController extends Controller
'home' => route('landing.index'),
'login' => route('login'),
'login_store' => route('login.store'),
'offer' => config('moonwell.legal.published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.published') ? route('legal.privacy') : null,
'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null,
],
'flash' => [
'status' => $request->session()->get('status'),
+2 -2
View File
@@ -124,8 +124,8 @@ class CabinetController extends Controller
'client' => route('cabinet.client'),
'password' => route('cabinet.password.update'),
'deposit' => route('cabinet.balance.deposit'),
'offer' => config('moonwell.legal.published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.published') ? route('legal.privacy') : null,
'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null,
];
}
}
@@ -31,8 +31,8 @@ trait BuildsPortalProps
'cabinet' => route('cabinet.index'),
'admin' => route('admin.accounts.index'),
'logout' => route('logout'),
'offer' => config('moonwell.legal.published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.published') ? route('legal.privacy') : null,
'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null,
'admin_accounts' => route('admin.accounts.index'),
'admin_balances' => route('admin.balances.index'),
'admin_shop' => route('admin.shop.index'),
+4 -3
View File
@@ -82,9 +82,10 @@ class LandingController extends Controller
],
'registerEndpoint' => route('game-account.store'),
'legalUrls' => [
'published' => (bool) config('moonwell.legal.published'),
'offer' => config('moonwell.legal.published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.published') ? route('legal.privacy') : null,
'offer_published' => (bool) config('moonwell.legal.offer_published'),
'privacy_published' => (bool) config('moonwell.legal.privacy_published'),
'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null,
],
'inviteRequired' => (bool) config('moonwell.registration.require_invite_code'),
'csrfToken' => csrf_token(),
+4 -4
View File
@@ -10,14 +10,14 @@ class LegalController extends Controller
{
public function offer(Request $request): Response
{
abort_unless(config('moonwell.legal.published'), 404);
abort_unless(config('moonwell.legal.offer_published'), 404);
return Inertia::render('Legal/Offer', $this->pageData($request));
}
public function privacy(Request $request): Response
{
abort_unless(config('moonwell.legal.published'), 404);
abort_unless(config('moonwell.legal.privacy_published'), 404);
return Inertia::render('Legal/Privacy', $this->pageData($request));
}
@@ -46,8 +46,8 @@ class LegalController extends Controller
'cabinet' => route('cabinet.index'),
'admin' => route('admin.accounts.index'),
'logout' => route('logout'),
'offer' => config('moonwell.legal.published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.published') ? route('legal.privacy') : null,
'offer' => config('moonwell.legal.offer_published') ? route('legal.offer') : null,
'privacy' => config('moonwell.legal.privacy_published') ? route('legal.privacy') : null,
],
'legal' => [
'operator_name' => config('moonwell.legal.operator_name'),
+3 -2
View File
@@ -20,10 +20,11 @@ return [
],
'legal' => [
'published' => (bool) env('MOONWELL_LEGAL_PUBLISHED', false),
'offer_published' => (bool) env('MOONWELL_OFFER_PUBLISHED', true),
'privacy_published' => (bool) env('MOONWELL_PRIVACY_PUBLISHED', false),
'operator_name' => env('MOONWELL_LEGAL_OPERATOR', 'Администрация проекта MoonWell'),
'contact_email' => env('MOONWELL_LEGAL_EMAIL', 'support@moon-well.online'),
'effective_date' => env('MOONWELL_LEGAL_EFFECTIVE_DATE', '25 июля 2026 года'),
'effective_date' => env('MOONWELL_LEGAL_EFFECTIVE_DATE', '26 июля 2026 года'),
],
'client' => [
+11
View File
@@ -2801,6 +2801,8 @@ body[data-particles="false"] #particles { display: none; }
top: 110px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 140px);
overflow-y: auto;
padding: 18px 0;
border-top: 1px solid var(--panel-border);
border-bottom: 1px solid var(--panel-border);
@@ -2832,6 +2834,15 @@ body[data-particles="false"] #particles { display: none; }
color: var(--text-dim);
}
.legal-document { padding: clamp(30px, 5vw, 58px); }
.legal-intro {
margin-bottom: 42px;
padding-bottom: 28px;
border-bottom: 1px solid var(--panel-border);
}
.legal-intro p:last-child {
overflow-wrap: anywhere;
color: var(--moon);
}
.legal-document section {
scroll-margin-top: 100px;
padding: 42px 0;
+20 -5
View File
@@ -4,6 +4,7 @@ defineProps({
title: { type: String, required: true },
description: { type: String, required: true },
effectiveDate: { type: String, required: true },
intro: { type: Array, default: () => [] },
sections: { type: Array, required: true },
});
</script>
@@ -30,14 +31,28 @@ defineProps({
</aside>
<article class="portal-card legal-document corners">
<div v-if="intro.length" class="legal-intro">
<p v-for="paragraph in intro" :key="paragraph">{{ paragraph }}</p>
</div>
<section v-for="(section, index) in sections" :id="section.id" :key="section.id">
<span class="legal-section-number">{{ String(index + 1).padStart(2, '0') }}</span>
<h2 class="h-serif">{{ section.title }}</h2>
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
<ul v-if="section.items">
<li v-for="item in section.items" :key="item">{{ item }}</li>
</ul>
<p v-if="section.note" class="legal-note">{{ section.note }}</p>
<template v-if="section.blocks">
<template v-for="(block, blockIndex) in section.blocks" :key="blockIndex">
<p v-if="block.type === 'paragraph'">{{ block.text }}</p>
<ul v-else-if="block.type === 'list'">
<li v-for="item in block.items" :key="item">{{ item }}</li>
</ul>
<p v-else-if="block.type === 'note'" class="legal-note">{{ block.text }}</p>
</template>
</template>
<template v-else>
<p v-for="paragraph in section.paragraphs" :key="paragraph">{{ paragraph }}</p>
<ul v-if="section.items">
<li v-for="item in section.items" :key="item">{{ item }}</li>
</ul>
<p v-if="section.note" class="legal-note">{{ section.note }}</p>
</template>
</section>
</article>
</div>
@@ -35,14 +35,18 @@ defineProps({
</div>
<p>Приватный сервер World of Warcraft 3.3.5a с личным прогрессом, одиночными подземельями и режимом «Предатель». Играй бесплатно покупки не дают боевого преимущества.</p>
</div>
<div class="footer-col"><h4>Сервер</h4><a href="#">Статус реалмов</a><a :href="playUrl">Скачать клиент</a><a v-if="legalUrls.published" :href="legalUrls.offer">Публичная оферта</a><a v-if="legalUrls.published" :href="legalUrls.privacy">Конфиденциальность</a></div>
<div class="footer-col"><h4>Сервер</h4><a href="#">Статус реалмов</a><a :href="playUrl">Скачать клиент</a><a v-if="legalUrls.offer_published" :href="legalUrls.offer">Публичная оферта</a><a v-if="legalUrls.privacy_published" :href="legalUrls.privacy">Конфиденциальность</a></div>
<div class="footer-col"><h4>Сообщество</h4><a href="#">Discord</a><a href="#">Форум</a><a href="#">Поддержка</a><a href="#">Вакансии</a></div>
<div class="footer-col"><h4>База знаний</h4><a href="#">Гайды</a><a href="#">База данных</a><a href="#">Рейтинги</a><a href="#">API</a></div>
</div>
<div class="footer-bottom">
<span>© 2026 MoonWell · фанатский проект · не связан с Blizzard Entertainment</span>
<span v-if="legalUrls.published"><a :href="legalUrls.offer">Оферта</a> · <a :href="legalUrls.privacy">Политика конфиденциальности</a></span>
<span v-if="legalUrls.offer_published || legalUrls.privacy_published">
<a v-if="legalUrls.offer_published" :href="legalUrls.offer">Оферта</a>
<template v-if="legalUrls.offer_published && legalUrls.privacy_published"> · </template>
<a v-if="legalUrls.privacy_published" :href="legalUrls.privacy">Политика конфиденциальности</a>
</span>
<span v-else>Сделано с лунным светом</span>
</div>
</div>
@@ -160,9 +160,9 @@ function submit(event) {
<label class="join-check">
<input type="checkbox" name="terms" value="1" :checked="form.terms" @change="updateField('terms', $event.target.checked)" />
<span class="box" aria-hidden="true"></span>
<span v-if="legalUrls.published">
Я принимаю <a :href="legalUrls.offer" target="_blank" rel="noopener">публичную оферту</a>
и <a :href="legalUrls.privacy" target="_blank" rel="noopener">политику конфиденциальности</a>.
<span v-if="legalUrls.offer_published || legalUrls.privacy_published">
Я принимаю
<a v-if="legalUrls.offer_published" :href="legalUrls.offer" target="_blank" rel="noopener">публичную оферту</a><template v-if="legalUrls.offer_published && legalUrls.privacy_published"> и </template><a v-if="legalUrls.privacy_published" :href="legalUrls.privacy" target="_blank" rel="noopener">политику конфиденциальности</a>.
</span>
<span v-else>Я принимаю правила проекта и согласен на обработку данных для создания игрового аккаунта.</span>
</label>
@@ -8,7 +8,7 @@ export const navLinks = [
export const features = [
['I', 'Главная особенность', 'Личный прогресс', 'Заверши путь в Классике, чтобы открыть Запределье, затем одолей Иллидана и отправляйся в Нортренд. Продвигайся в своём темпе.', 'span-6', 'path'],
['II', '', 'Одиночные подземелья', 'Проходи подземелья самостоятельно или с отрядом до пяти игроков: боссы и механики подстраиваются под размер группы.', 'span-3', 'solo'],
['III', 'В разработке', 'Режим «Предатель»', 'Создай персонажа Альянса на стороне Орды или персонажа Орды на стороне Альянса. Тебя ждут отдельные сюжеты, персонажи и репутации.', 'span-3', 'mask'],
['III', 'В разработке ', 'Режим «Предатель»', 'Создай персонажа Альянса на стороне Орды или персонажа Орды на стороне Альянса. Тебя ждут отдельные сюжеты, персонажи и репутации.', 'span-3', 'mask'],
['IV', 'В разработке', 'Ликантропия', 'Заразись ликантропией, сопротивляйся ей или прими новую форму. Выбор определит твой путь.', 'span-4', 'wolf'],
['V', '', 'Мифик+ без предела', 'Уровень ключей не ограничен: сложность продолжает расти вместе с твоим прогрессом, а набор модификаторов меняется каждую неделю.', 'span-4', 'mythic'],
['VI', 'В разработке', 'Боевой пропуск · магазин наград', 'Выполняй ежедневные и еженедельные задания, получай награды за вход и открывай косметические предметы.', 'span-4', 'pass'],
+12 -85
View File
@@ -1,109 +1,36 @@
<script setup>
import { Head } from '@inertiajs/vue3';
import { computed } from 'vue';
import LegalDocument from '../../Components/LegalDocument.vue';
import PortalLayout from '../../Layouts/PortalLayout.vue';
import {
offerEffectiveDate,
offerIntro,
offerSections,
offerSubtitle,
} from './offerContent';
const props = defineProps({
defineProps({
site: { type: Object, required: true },
auth: { type: Object, required: true },
urls: { type: Object, required: true },
legal: { type: Object, required: true },
});
const sections = computed(() => [
{
id: 'general',
title: 'Общие положения',
paragraphs: [
`Настоящий документ является публичным предложением ${props.legal.operator_name} заключить соглашение об использовании сервисов проекта ${props.site.name} на изложенных ниже условиях.`,
'Пользователем считается дееспособное лицо, которое зарегистрировало игровой аккаунт, использует сайт, лаунчер или игровые сервисы проекта. Регистрация аккаунта, использование сервисов либо совершение платежа означает полное и безоговорочное принятие условий оферты.',
'Проект является неофициальным фанатским проектом и не связан с Blizzard Entertainment, Inc. Все права на упомянутые товарные знаки и игровые материалы принадлежат их правообладателям.',
],
},
{
id: 'service',
title: 'Предмет соглашения',
paragraphs: [
'Администрация предоставляет Пользователю ограниченный, отзывный и непередаваемый доступ к сайту, личному кабинету, лаунчеру и игровому серверу в их текущем состоянии.',
'Доступ предоставляется исключительно для личного некоммерческого использования. Игровой аккаунт, персонажи, внутриигровые предметы и виртуальная валюта не являются собственностью Пользователя и не имеют гарантированной денежной стоимости.',
],
items: [
'создание и использование одного или нескольких игровых аккаунтов по правилам проекта;',
'доступ к персонажам, игровому прогрессу и функциям личного кабинета;',
'получение обновлений клиента через лаунчер;',
'приобретение виртуальной валюты «Слёзы Элуны» для использования внутри проекта.',
],
},
{
id: 'payments',
title: 'Платежи и виртуальная валюта',
paragraphs: [
'Пользователь вправе добровольно пополнять баланс личного кабинета. Размер начисления, курс, минимальная и максимальная сумма отображаются до совершения платежа.',
'Оплата проводится сторонним платёжным оператором. Администрация не получает и не хранит полные реквизиты банковской карты. Обязательство по начислению считается исполненным после подтверждения успешной оплаты платёжным оператором и отражения суммы в личном кабинете.',
'Слёзы Элуны используются только внутри проекта, не являются электронными деньгами, ценной бумагой или средством платежа и не подлежат обмену между пользователями на реальные деньги.',
],
},
{
id: 'refunds',
title: 'Возвраты и ошибки начисления',
paragraphs: [
'При технической ошибке, двойном списании или отсутствии начисления Пользователь должен обратиться в поддержку и сообщить логин аккаунта, дату, сумму и идентификатор платежа.',
'Решение о возврате принимается с учётом статуса платежа и факта использования начисленной виртуальной валюты. Если цифровое предоставление началось с согласия Пользователя и начисление уже использовано полностью или частично, возврат может быть ограничен в пределах, допускаемых применимым законодательством.',
'Возврат при наличии оснований производится тем же способом, которым была совершена оплата, если иной порядок не согласован сторонами.',
],
},
{
id: 'rules',
title: 'Права и обязанности',
paragraphs: [
'Пользователь обязан предоставлять достоверные данные, обеспечивать сохранность учётных данных, соблюдать правила проекта и не нарушать работу сайта, лаунчера и игрового сервера.',
'Запрещены автоматизация игрового процесса без разрешения, вмешательство в программный код или сетевой обмен, эксплуатация ошибок, мошенничество, продажа аккаунтов и виртуальных ценностей за реальные деньги, а также действия, нарушающие права других лиц.',
'Администрация вправе обновлять сервисы, проводить технические работы, исправлять баланс и игровые ошибки, а при нарушении правил — ограничивать или прекращать доступ к аккаунту.',
],
},
{
id: 'liability',
title: 'Ответственность',
paragraphs: [
'Сервисы предоставляются «как есть». Администрация стремится обеспечивать стабильную работу, но не гарантирует непрерывность доступа, отсутствие ошибок или сохранение конкретных игровых механик и значений.',
'Стороны освобождаются от ответственности за неисполнение обязательств вследствие обстоятельств непреодолимой силы, сбоев инфраструктуры третьих лиц, действий государственных органов и иных обстоятельств вне разумного контроля.',
'Ничто в оферте не ограничивает права Пользователя, которые не могут быть ограничены по закону.',
],
},
{
id: 'changes',
title: 'Изменение и прекращение',
paragraphs: [
'Администрация вправе изменять оферту. Новая редакция публикуется на этой странице и применяется с указанной даты вступления в силу. Продолжение использования сервисов после этой даты означает согласие с новой редакцией.',
'Пользователь может прекратить использование сервисов в любое время. Администрация может прекратить работу проекта или отдельных функций, уведомив пользователей доступным способом, когда это разумно возможно.',
],
},
{
id: 'contacts',
title: 'Контакты и споры',
paragraphs: [
`По вопросам платежей, доступа и условий оферты напишите на ${props.legal.contact_email}. Обращение должно содержать достаточно информации для идентификации аккаунта и ситуации.`,
'Стороны стремятся урегулировать разногласия путём переговоров. Если договориться не удаётся, спор разрешается в порядке, установленном применимым законодательством.',
],
note: `Исполнитель: ${props.legal.operator_name}. Контактный адрес: ${props.legal.contact_email}.`,
},
]);
</script>
<template>
<Head>
<title>Публичная оферта {{ site.name }}</title>
<meta name="description" content="Публичная оферта игрового проекта MoonWell." />
<meta name="description" content="Публичная оферта о заключении договора возмездного оказания цифровых услуг MoonWell." />
</Head>
<PortalLayout :site="site" :auth="auth" :urls="urls">
<LegalDocument
eyebrow="Юридическая информация"
title="Публичная оферта"
description="Условия доступа к сервисам MoonWell, пополнения баланса и использования виртуальной валюты."
:effective-date="legal.effective_date"
:sections="sections"
:description="offerSubtitle"
:effective-date="offerEffectiveDate"
:intro="offerIntro"
:sections="offerSections"
/>
</PortalLayout>
</template>
File diff suppressed because it is too large Load Diff
+17 -12
View File
@@ -19,31 +19,36 @@ class InertiaPublicPagesFeatureTest extends TestCase
);
}
public function test_hidden_legal_pages_return_not_found(): void
public function test_offer_is_published_while_privacy_policy_stays_hidden(): void
{
$this->get(route('legal.offer'))->assertNotFound();
$this->get(route('legal.privacy'))->assertNotFound();
}
public function test_legal_pages_can_be_published_by_configuration(): void
{
config(['moonwell.legal.published' => true]);
$this->get(route('legal.offer'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Legal/Offer', false)
->where('legal.operator_name', config('moonwell.legal.operator_name'))
->where('urls.privacy', route('legal.privacy'))
->where('urls.offer', route('legal.offer'))
->where('urls.privacy', null)
->etc()
);
$this->get(route('legal.privacy'))->assertNotFound();
}
public function test_legal_pages_can_be_controlled_independently(): void
{
config([
'moonwell.legal.offer_published' => false,
'moonwell.legal.privacy_published' => true,
]);
$this->get(route('legal.offer'))->assertNotFound();
$this->get(route('legal.privacy'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Legal/Privacy', false)
->where('legal.contact_email', config('moonwell.legal.contact_email'))
->where('urls.offer', route('legal.offer'))
->where('urls.offer', null)
->where('urls.privacy', route('legal.privacy'))
->etc()
);
}
+3 -2
View File
@@ -17,8 +17,9 @@ class LandingPageTest extends TestCase
$response->assertInertia(fn (Assert $page) => $page
->component('Landing/Main', false)
->where('realm.server_name', 'MoonWell')
->where('legalUrls.published', false)
->where('legalUrls.offer', null)
->where('legalUrls.offer_published', true)
->where('legalUrls.privacy_published', false)
->where('legalUrls.offer', route('legal.offer'))
->where('legalUrls.privacy', null)
->etc()
);
Binary file not shown.