Добавлен SHiNE-promo-solana-devnet в основной репозиторий без вложенного git

This commit is contained in:
AidarKC
2026-05-01 13:58:59 +03:00
parent 9b03273055
commit 3061bf3d1e
25 changed files with 1812 additions and 0 deletions
@@ -0,0 +1,30 @@
package ru.shine.promo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import ru.shine.promo.config.AppProperties;
@SpringBootApplication
public class ShinePromoSolanaDevnetApplication {
private static final Logger log = LoggerFactory.getLogger(ShinePromoSolanaDevnetApplication.class);
public static void main(String[] args) {
SpringApplication.run(ShinePromoSolanaDevnetApplication.class, args);
}
@Bean
CommandLineRunner startupLogger(AppProperties appProperties) {
return args -> log.info(
"SHiNE promo app started. RPC: {}, promoCodesFile: {}, usedFile: {}, transferAmountSol: {}",
appProperties.getSolanaRpcUrl(),
appProperties.getPromoCodesFile(),
appProperties.getPromoUsedFile(),
appProperties.getPromoTransferAmountSol()
);
}
}
@@ -0,0 +1,77 @@
package ru.shine.promo.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
@Component
public class AppProperties {
private static final long LAMPORTS_PER_SOL = 1_000_000_000L;
@Value("${solana.rpc.url}")
private String solanaRpcUrl;
@Value("${solana.sender.keypair-file}")
private String solanaSenderKeypairFile;
@Value("${promo.transfer.amount-sol}")
private BigDecimal promoTransferAmountSol;
@Value("${promo.codes.file}")
private String promoCodesFile;
@Value("${promo.used.file}")
private String promoUsedFile;
@Value("${promo.explorer.tx-url-template}")
private String promoExplorerTxUrlTemplate;
@Value("${promo.eternal-code.enabled:false}")
private boolean promoEternalCodeEnabled;
@Value("${promo.eternal-code.value:0000}")
private String promoEternalCodeValue;
public String getSolanaRpcUrl() {
return solanaRpcUrl;
}
public String getSolanaSenderKeypairFile() {
return solanaSenderKeypairFile;
}
public BigDecimal getPromoTransferAmountSol() {
return promoTransferAmountSol;
}
public String getPromoCodesFile() {
return promoCodesFile;
}
public String getPromoUsedFile() {
return promoUsedFile;
}
public String getPromoExplorerTxUrlTemplate() {
return promoExplorerTxUrlTemplate;
}
public boolean isPromoEternalCodeEnabled() {
return promoEternalCodeEnabled;
}
public String getPromoEternalCodeValue() {
if (promoEternalCodeValue == null) {
return "";
}
return promoEternalCodeValue.trim().toLowerCase();
}
public long getPromoTransferAmountLamports() {
BigDecimal lamports = promoTransferAmountSol.multiply(BigDecimal.valueOf(LAMPORTS_PER_SOL));
return lamports.setScale(0, RoundingMode.UNNECESSARY).longValueExact();
}
}
@@ -0,0 +1,136 @@
package ru.shine.promo.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import ru.shine.promo.config.AppProperties;
import ru.shine.promo.dto.PromoRequest;
import ru.shine.promo.dto.PromoResponse;
import ru.shine.promo.service.PromoCodeService;
import ru.shine.promo.service.PromoException;
import ru.shine.promo.service.PromoTransferService;
import ru.shine.promo.service.UsedPromoStorageService;
import java.util.Map;
@RestController
public class PromoApiController {
private static final Logger log = LoggerFactory.getLogger(PromoApiController.class);
private final AppProperties appProperties;
private final PromoCodeService promoCodeService;
private final PromoTransferService promoTransferService;
private final UsedPromoStorageService usedPromoStorageService;
public PromoApiController(
AppProperties appProperties,
PromoCodeService promoCodeService,
PromoTransferService promoTransferService,
UsedPromoStorageService usedPromoStorageService
) {
this.appProperties = appProperties;
this.promoCodeService = promoCodeService;
this.promoTransferService = promoTransferService;
this.usedPromoStorageService = usedPromoStorageService;
}
@PostMapping("/api/promo/top-up")
public ResponseEntity<PromoResponse> topUp(@RequestBody PromoRequest request) {
String wallet = trimToEmpty(request == null ? null : request.getWallet());
String name = trimToEmpty(request == null ? null : request.getName());
String promoCode = promoCodeService.normalizePromoCode(request == null ? null : request.getPromoCode());
if (wallet.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустой wallet");
}
if (!promoTransferService.isSolanaWalletValid(wallet)) {
return error(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса");
}
if (name.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустое имя");
}
if (name.length() < 2 || name.length() > 120) {
return error(HttpStatus.BAD_REQUEST, "Имя должно быть длиной от 2 до 120 символов");
}
if (promoCode.isEmpty()) {
return error(HttpStatus.BAD_REQUEST, "Пустой промокод");
}
boolean eternalPromo = promoCodeService.isEternalPromoCode(promoCode);
if (!eternalPromo && !promoCodeService.isPromoCodeFormatValid(promoCode)) {
return error(HttpStatus.BAD_REQUEST, "Неверный формат промокода");
}
try {
if (!eternalPromo && !promoCodeService.promoCodeExists(promoCode)) {
return error(HttpStatus.BAD_REQUEST, "Промокод не найден");
}
String signature = usedPromoStorageService.executeLocked(() -> {
if (!eternalPromo && usedPromoStorageService.isPromoUsed(promoCode)) {
throw new PromoException(HttpStatus.CONFLICT, "Промокод уже использован");
}
String txSignature = promoTransferService.sendPromoTransfer(wallet);
// Для вечного промокода ведём только лог использования, но не блокируем повторное применение.
usedPromoStorageService.appendUsedPromo(promoCode, wallet, name, txSignature);
return txSignature;
});
String explorerUrl = promoTransferService.buildExplorerUrl(signature);
String amount = appProperties.getPromoTransferAmountSol().stripTrailingZeros().toPlainString();
log.info(
"Promo top-up success: wallet={}, name={}, promoCode={}, signature={}",
wallet,
name,
promoCode,
signature
);
PromoResponse response = PromoResponse.success(
"Тестовое пополнение выполнено",
wallet,
name,
amount,
signature,
explorerUrl
);
return ResponseEntity.ok(response);
} catch (PromoException e) {
if (e.getStatus().is5xxServerError()) {
log.error("Top-up failed: {}", e.getMessage(), e);
} else {
log.warn("Top-up rejected: {}", e.getMessage());
}
return error(e.getStatus(), e.getMessage());
} catch (Exception e) {
log.error("Unexpected top-up error", e);
return error(HttpStatus.INTERNAL_SERVER_ERROR, "Внутренняя ошибка сервера");
}
}
@GetMapping("/health")
public Map<String, String> health() {
return Map.of(
"status", "ok",
"app", "SHiNE-promo-solana-devnet"
);
}
private ResponseEntity<PromoResponse> error(HttpStatus status, String message) {
return ResponseEntity.status(status).body(PromoResponse.error(message));
}
private String trimToEmpty(String value) {
if (value == null) {
return "";
}
return value.trim().replaceAll("\\s{2,}", " ");
}
}
@@ -0,0 +1,16 @@
package ru.shine.promo.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class PromoPageController {
@GetMapping("/")
public String index(@RequestParam(name = "wallet", required = false) String wallet, Model model) {
model.addAttribute("wallet", wallet == null ? "" : wallet.trim());
return "index";
}
}
@@ -0,0 +1,32 @@
package ru.shine.promo.dto;
public class PromoRequest {
private String wallet;
private String name;
private String promoCode;
public String getWallet() {
return wallet;
}
public void setWallet(String wallet) {
this.wallet = wallet;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getPromoCode() {
return promoCode;
}
public void setPromoCode(String promoCode) {
this.promoCode = promoCode;
}
}
@@ -0,0 +1,69 @@
package ru.shine.promo.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PromoResponse {
private boolean success;
private String message;
private String wallet;
private String name;
private String amountSol;
private String signature;
private String explorerUrl;
public static PromoResponse success(
String message,
String wallet,
String name,
String amountSol,
String signature,
String explorerUrl
) {
PromoResponse response = new PromoResponse();
response.success = true;
response.message = message;
response.wallet = wallet;
response.name = name;
response.amountSol = amountSol;
response.signature = signature;
response.explorerUrl = explorerUrl;
return response;
}
public static PromoResponse error(String message) {
PromoResponse response = new PromoResponse();
response.success = false;
response.message = message;
return response;
}
public boolean isSuccess() {
return success;
}
public String getMessage() {
return message;
}
public String getWallet() {
return wallet;
}
public String getName() {
return name;
}
public String getAmountSol() {
return amountSol;
}
public String getSignature() {
return signature;
}
public String getExplorerUrl() {
return explorerUrl;
}
}
@@ -0,0 +1,77 @@
package ru.shine.promo.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Pattern;
@Service
public class PromoCodeService {
private static final Logger log = LoggerFactory.getLogger(PromoCodeService.class);
private static final Pattern PROMO_CODE_PATTERN = Pattern.compile("^[a-z0-9]{8}$");
private final AppProperties appProperties;
public PromoCodeService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public String normalizePromoCode(String rawPromoCode) {
if (rawPromoCode == null) {
return "";
}
return rawPromoCode.trim().toLowerCase();
}
public boolean isPromoCodeFormatValid(String promoCode) {
return PROMO_CODE_PATTERN.matcher(promoCode).matches();
}
public boolean isEternalPromoCode(String promoCode) {
if (!appProperties.isPromoEternalCodeEnabled()) {
return false;
}
String configured = appProperties.getPromoEternalCodeValue();
return !configured.isEmpty() && configured.equals(promoCode);
}
public boolean promoCodeExists(String promoCode) {
Set<String> codes = readPromoCodesFromFile();
return codes.contains(promoCode);
}
private Set<String> readPromoCodesFromFile() {
Path file = Path.of(appProperties.getPromoCodesFile());
if (!Files.exists(file)) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов");
}
try {
Set<String> result = new LinkedHashSet<>();
for (String row : Files.readAllLines(file, StandardCharsets.UTF_8)) {
String line = row.trim().toLowerCase();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
if (!isPromoCodeFormatValid(line)) {
log.warn("Skipped invalid promo code row in {}: {}", file, line);
continue;
}
result.add(line);
}
return result;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла промокодов", e);
}
}
}
@@ -0,0 +1,22 @@
package ru.shine.promo.service;
import org.springframework.http.HttpStatus;
public class PromoException extends RuntimeException {
private final HttpStatus status;
public PromoException(HttpStatus status, String message) {
super(message);
this.status = status;
}
public PromoException(HttpStatus status, String message, Throwable cause) {
super(message, cause);
this.status = status;
}
public HttpStatus getStatus() {
return status;
}
}
@@ -0,0 +1,139 @@
package ru.shine.promo.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.p2p.solanaj.core.Account;
import org.p2p.solanaj.core.PublicKey;
import org.p2p.solanaj.core.Transaction;
import org.p2p.solanaj.programs.SystemProgram;
import org.p2p.solanaj.rpc.RpcClient;
import org.p2p.solanaj.rpc.RpcException;
import org.p2p.solanaj.rpc.types.config.Commitment;
import org.p2p.solanaj.rpc.types.config.RpcSendTransactionConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.Locale;
@Service
public class PromoTransferService {
private static final Logger log = LoggerFactory.getLogger(PromoTransferService.class);
private final AppProperties appProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
public PromoTransferService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public boolean isSolanaWalletValid(String wallet) {
if (wallet == null || wallet.isBlank()) {
return false;
}
try {
PublicKey publicKey = new PublicKey(wallet.trim());
return publicKey.toByteArray().length == PublicKey.PUBLIC_KEY_LENGTH;
} catch (Exception e) {
return false;
}
}
public String sendPromoTransfer(String recipientWallet) {
PublicKey recipient = parseWalletOrThrow(recipientWallet);
Account sender = readSenderAccount();
long lamports = appProperties.getPromoTransferAmountLamports();
RpcClient rpcClient = new RpcClient(appProperties.getSolanaRpcUrl());
try {
long senderBalance = rpcClient.getApi().getBalance(sender.getPublicKey(), Commitment.CONFIRMED);
if (senderBalance < lamports) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя");
}
Transaction transaction = new Transaction()
.addInstruction(SystemProgram.transfer(sender.getPublicKey(), recipient, lamports));
RpcSendTransactionConfig config = RpcSendTransactionConfig.builder()
.encoding(RpcSendTransactionConfig.Encoding.base64)
.skipPreFlight(false)
.maxRetries(3)
.build();
return rpcClient.getApi().sendTransaction(
transaction,
Collections.singletonList(sender),
null,
config
);
} catch (PromoException e) {
throw e;
} catch (RpcException e) {
String message = e.getMessage() == null ? "" : e.getMessage().toLowerCase(Locale.ROOT);
if (message.contains("insufficient")) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Недостаточно средств на devnet-кошельке отправителя", e);
}
if (message.contains("rpc")) {
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка RPC Solana", e);
}
log.error("Solana transfer failed: {}", e.getMessage(), e);
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e);
} catch (Exception e) {
log.error("Unexpected Solana transfer error: {}", e.getMessage(), e);
throw new PromoException(HttpStatus.BAD_GATEWAY, "Ошибка отправки транзакции", e);
}
}
public String buildExplorerUrl(String signature) {
return String.format(appProperties.getPromoExplorerTxUrlTemplate(), signature);
}
private PublicKey parseWalletOrThrow(String wallet) {
if (!isSolanaWalletValid(wallet)) {
throw new PromoException(HttpStatus.BAD_REQUEST, "Неверный формат Solana-адреса");
}
return new PublicKey(wallet.trim());
}
private Account readSenderAccount() {
Path keypairFile = Path.of(appProperties.getSolanaSenderKeypairFile());
if (!Files.exists(keypairFile)) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Файл keypair отправителя не найден");
}
try {
int[] keyArray = objectMapper.readValue(Files.readString(keypairFile), int[].class);
if (keyArray.length != 64) {
throw new PromoException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Некорректный формат keypair отправителя: ожидается массив из 64 чисел"
);
}
byte[] secret = new byte[64];
for (int i = 0; i < keyArray.length; i++) {
int value = keyArray[i];
if (value < 0 || value > 255) {
throw new PromoException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Некорректный формат keypair отправителя: числа должны быть в диапазоне 0..255"
);
}
secret[i] = (byte) value;
}
return new Account(secret);
} catch (PromoException e) {
throw e;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения keypair отправителя", e);
}
}
}
@@ -0,0 +1,101 @@
package ru.shine.promo.service;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import ru.shine.promo.config.AppProperties;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.locks.ReentrantLock;
@Service
public class UsedPromoStorageService {
private static final DateTimeFormatter DATE_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm", Locale.ROOT);
private final AppProperties appProperties;
private final ReentrantLock promoLock = new ReentrantLock(true);
public UsedPromoStorageService(AppProperties appProperties) {
this.appProperties = appProperties;
}
public <T> T executeLocked(LockedOperation<T> operation) {
promoLock.lock();
try {
return operation.run();
} finally {
promoLock.unlock();
}
}
public boolean isPromoUsed(String promoCode) {
Path usedFile = Path.of(appProperties.getPromoUsedFile());
if (!Files.exists(usedFile)) {
return false;
}
try {
List<String> rows = Files.readAllLines(usedFile, StandardCharsets.UTF_8);
for (String row : rows) {
String line = row.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] parts = line.split("\\|");
if (parts.length == 0) {
continue;
}
String usedCode = parts[0].trim().toLowerCase(Locale.ROOT);
if (usedCode.equals(promoCode)) {
return true;
}
}
return false;
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка чтения файла использованных промокодов", e);
}
}
public void appendUsedPromo(String promoCode, String wallet, String name, String signature) {
Path usedFile = Path.of(appProperties.getPromoUsedFile());
try {
Path parent = usedFile.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
if (!Files.exists(usedFile)) {
Files.createFile(usedFile);
}
String timestamp = LocalDateTime.now().format(DATE_TIME_FORMAT);
String line = String.format(
Locale.ROOT,
"%s | %s | %s | %s | %s%n",
promoCode,
wallet,
name,
timestamp,
signature
);
Files.writeString(usedFile, line, StandardCharsets.UTF_8, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new PromoException(HttpStatus.INTERNAL_SERVER_ERROR, "Ошибка записи файла использованных промокодов", e);
}
}
@FunctionalInterface
public interface LockedOperation<T> {
T run();
}
}
@@ -0,0 +1,17 @@
server.port=8021
spring.application.name=SHiNE-promo-solana-devnet
spring.thymeleaf.cache=false
solana.rpc.url=https://api.devnet.solana.com
solana.sender.keypair-file=./config/devnet-wallet.json
promo.transfer.amount-sol=0.1
promo.codes.file=./data/promo-codes.txt
promo.used.file=./data/promo-used.txt
promo.explorer.tx-url-template=https://explorer.solana.com/tx/%s?cluster=devnet
# Вечный промокод для временной раздачи в devnet.
# Если enabled=true, код можно использовать неограниченно (он не "сгорает").
promo.eternal-code.enabled=true
promo.eternal-code.value=0000
@@ -0,0 +1,200 @@
:root {
--bg-main: #070707;
--bg-card: #111217;
--bg-card-soft: #171a22;
--text-main: #f6f8fb;
--text-muted: #aeb4c1;
--accent: #51ffd3;
--accent-soft: rgba(81, 255, 211, 0.15);
--danger: #ff6f6f;
--border: rgba(255, 255, 255, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: "Segoe UI", "Noto Sans", system-ui, sans-serif;
color: var(--text-main);
background:
radial-gradient(circle at 20% 5%, rgba(81, 255, 211, 0.08), transparent 34%),
radial-gradient(circle at 100% 0%, rgba(86, 135, 255, 0.12), transparent 40%),
var(--bg-main);
min-height: 100vh;
}
.page {
min-height: calc(100vh - 48px);
display: grid;
place-items: center;
padding: 24px 14px;
}
.card {
width: min(720px, 100%);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01));
border: 1px solid var(--border);
border-radius: 18px;
padding: 20px;
box-shadow: 0 12px 50px rgba(0, 0, 0, 0.45);
}
.hero h1 {
margin: 0;
font-size: clamp(1.3rem, 3.8vw, 1.9rem);
line-height: 1.2;
}
.subtitle {
color: var(--text-muted);
margin-top: 10px;
margin-bottom: 0;
}
.lead {
margin-top: 18px;
line-height: 1.5;
}
.warning {
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
padding: 12px;
margin-top: 16px;
color: #f0f4ff;
}
.promo-form {
display: grid;
gap: 10px;
margin-top: 18px;
}
.promo-form label {
font-size: 0.95rem;
color: var(--text-muted);
}
.promo-form input {
width: 100%;
background: var(--bg-card-soft);
color: var(--text-main);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px 12px;
font-size: 1rem;
}
.promo-form input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.promo-form button {
margin-top: 8px;
border: none;
border-radius: 12px;
background: linear-gradient(140deg, #2dd4bf, #4cc9f0);
color: #0b1116;
font-weight: 700;
font-size: 1rem;
padding: 14px;
cursor: pointer;
}
.promo-form button:disabled {
opacity: 0.7;
cursor: default;
}
.status {
margin-top: 14px;
padding: 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border);
}
.status-error {
color: #ffd6d6;
border-color: rgba(255, 111, 111, 0.6);
background: rgba(255, 111, 111, 0.12);
}
.success {
margin-top: 16px;
border: 1px solid rgba(81, 255, 211, 0.35);
background: rgba(81, 255, 211, 0.08);
border-radius: 12px;
padding: 14px;
}
.success h2 {
margin-top: 0;
font-size: 1.12rem;
}
.success p {
margin-top: 8px;
line-height: 1.45;
}
.success ul {
padding-left: 18px;
margin: 10px 0;
display: grid;
gap: 6px;
word-break: break-word;
}
.success a {
color: var(--accent);
}
.faq {
margin-top: 20px;
}
.faq h3 {
margin-bottom: 10px;
}
.faq details {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 8px;
}
.faq summary {
cursor: pointer;
font-weight: 600;
}
.faq p {
margin-bottom: 4px;
color: var(--text-muted);
line-height: 1.45;
}
.page-footer {
text-align: center;
color: #727a88;
font-size: 0.8rem;
padding-bottom: 18px;
}
.hidden {
display: none;
}
@media (min-width: 768px) {
.card {
padding: 26px;
}
}
@@ -0,0 +1,82 @@
(() => {
const form = document.getElementById("promoForm");
const walletInput = document.getElementById("wallet");
const nameInput = document.getElementById("name");
const promoCodeInput = document.getElementById("promoCode");
const submitButton = document.getElementById("submitButton");
const loadingState = document.getElementById("loadingState");
const errorState = document.getElementById("errorState");
const successState = document.getElementById("successState");
const successAmount = document.getElementById("successAmount");
const successWallet = document.getElementById("successWallet");
const successName = document.getElementById("successName");
const successSignature = document.getElementById("successSignature");
const successExplorerUrl = document.getElementById("successExplorerUrl");
const params = new URLSearchParams(window.location.search);
const walletFromQuery = (params.get("wallet") || "").trim();
if (walletFromQuery && !walletInput.value.trim()) {
walletInput.value = walletFromQuery;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
hideMessages();
toggleLoading(true);
try {
const payload = {
wallet: walletInput.value.trim(),
name: nameInput.value.trim(),
promoCode: promoCodeInput.value.trim()
};
const response = await fetch("/api/promo/top-up", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const result = await response.json();
if (!result.success) {
showError(result.message || "Не удалось выполнить операцию");
return;
}
showSuccess(result);
} catch (error) {
showError("Ошибка сети или недоступен backend");
} finally {
toggleLoading(false);
}
});
function toggleLoading(active) {
submitButton.disabled = active;
loadingState.classList.toggle("hidden", !active);
submitButton.textContent = active ? "Отправляем..." : "Пополнить тестовый счёт";
}
function hideMessages() {
errorState.classList.add("hidden");
successState.classList.add("hidden");
}
function showError(message) {
errorState.textContent = message;
errorState.classList.remove("hidden");
}
function showSuccess(data) {
successAmount.textContent = data.amountSol || "0.1";
successWallet.textContent = data.wallet || "—";
successName.textContent = data.name || "—";
successSignature.textContent = data.signature || "—";
successExplorerUrl.href = data.explorerUrl || "#";
successState.classList.remove("hidden");
}
})();
@@ -0,0 +1,137 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SHiNE / Сияние — тестовое пополнение</title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<main class="page">
<section class="card">
<header class="hero">
<h1>SHiNE / Сияние — тестовое пополнение</h1>
<p class="subtitle">
Временная devnet-страница для приглашённых тестеров Web3-социальной сети SHiNE.
</p>
</header>
<p class="lead">
Если вы получили промокод, введите его ниже. Мы отправим на ваш Solana devnet-кошелёк 0.1 SOL для
тестирования функций SHiNE.
</p>
<div class="warning">
Это тестовая сеть Solana Devnet. Эти SOL не являются настоящими деньгами и используются только для
проверки работы приложения.
</div>
<form id="promoForm" class="promo-form" novalidate>
<label for="wallet">Кошелёк Solana</label>
<input id="wallet" name="wallet" type="text" th:value="${wallet}" placeholder="Введите публичный адрес"
required>
<label for="name">Ваше имя / имя тестера</label>
<input id="name" name="name" type="text" maxlength="120" placeholder="Например, Иван Петров" required>
<label for="promoCode">Промокод</label>
<input id="promoCode" name="promoCode" type="text" maxlength="8" placeholder="Например, x7k3m2qn"
required>
<button id="submitButton" type="submit">Пополнить тестовый счёт</button>
</form>
<div id="loadingState" class="status hidden">Отправляем транзакцию...</div>
<div id="errorState" class="status status-error hidden"></div>
<section id="successState" class="success hidden" aria-live="polite">
<h2>Готово! Тестовое пополнение выполнено</h2>
<p>
На указанный devnet-кошелёк отправлено 0.1 SOL. Возвращайтесь в приложение SHiNE / Сияние и
продолжайте тестирование.
</p>
<p>
Можете закрыть эту страницу и продолжить регистрацию на сайте.
</p>
<ul>
<li><strong>Сумма:</strong> <span id="successAmount"></span> SOL</li>
<li><strong>Кошелёк:</strong> <span id="successWallet"></span></li>
<li><strong>Имя:</strong> <span id="successName"></span></li>
<li><strong>Подпись транзакции:</strong> <span id="successSignature"></span></li>
</ul>
<a id="successExplorerUrl" href="#" target="_blank" rel="noopener noreferrer">
Открыть транзакцию в Solana Explorer
</a>
</section>
<section class="faq">
<h3>FAQ</h3>
<details>
<summary>Что такое SHiNE / Сияние?</summary>
<p>
SHiNE / «Сияние» — это тестируемая Web3-социальная сеть, где аккаунты, ключи и часть действий
связаны с блокчейном Solana. Пользователь управляет своим кошельком, а не просто логином и
паролем на обычном сервере.
</p>
</details>
<details>
<summary>Зачем нужен тестовый баланс?</summary>
<p>
В SHiNE регистрация и некоторые действия требуют небольших списаний. Это помогает тестировать
реальную экономику приложения: регистрацию, переводы, сообщения, звонки и другие функции. Сейчас
всё работает в Solana Devnet, поэтому средства тестовые.
</p>
</details>
<details>
<summary>Сколько стоит регистрация?</summary>
<p>
Текущая тестовая стоимость регистрации в SHiNE — 0.1 SOL. Промо-страница отправляет стартовое
тестовое пополнение 0.1 SOL. Условия тестирования могут меняться по мере развития проекта.
</p>
</details>
<details>
<summary>Можно ли пригласить друга?</summary>
<p>
Да. После получения тестового баланса и регистрации в SHiNE вы сможете переводить часть тестовых
SOL другим пользователям, например друзьям или родственникам, чтобы вместе проверить сообщения,
звонки и взаимодействие внутри социальной сети.
</p>
</details>
<details>
<summary>Это настоящие деньги?</summary>
<p>
Нет. Это Solana Devnet — тестовая сеть. Devnet SOL не имеют реальной рыночной ценности и
используются только для разработки и тестирования.
</p>
</details>
<details>
<summary>Почему в Web3 действия платные?</summary>
<p>
В Web3 часть действий связана с транзакциями, хранением данных, подписями и сетевой
инфраструктурой. Зато такая модель позволяет строить систему без навязчивой рекламы и с большей
прозрачностью: пользователь понимает, за что платит, а ключи остаются у него.
</p>
</details>
<details>
<summary>Кто хранит мои ключи?</summary>
<p>
Эта промо-страница не просит и не хранит приватные ключи пользователя. Для пополнения нужен только
публичный адрес кошелька Solana Devnet.
</p>
</details>
</section>
</section>
</main>
<footer class="page-footer">SHiNE Devnet Promo · temporary testing page</footer>
<script src="/js/app.js"></script>
</body>
</html>