爆速でflaskでショッピングカートを作成してみました
1手数料を日本一安くしたかった
なんだかんだでshopfyやbaseだと手数料が少し高かったり40円買われるごとに強制徴収されたりします。一見3.7%以下で安い!と思いきや最終的にはお金を支払わなければいけないシステムのようです。もちろん両社とも早く手軽に簡単にショッピングカートが利用できる素晴らしいサービスです。便利だし早いし素敵です。
flaskでショッピングカートを構築すればSTRIPEの手数料だけでいいのではないかと勘案し今回制作しました 完全レスポンシブルデザインでスマホでもタブレットでも購入できるデジタルコンテンツ仕様です。動画や画像やZIPや音楽やPDFが購入可能です。
少しでも いいね くれると励みになります。いいねが多くなればもう少しプログラムも公開するかもしれません。
toplist.html
{% extends "base.html" %}
{% block title %}一覧 - {{ super() }}{% endblock %}
{% block head_extra %}
<style>
.product-card {
transition: transform 0.2s;
}
.product-card:hover {
transform: translateY(-5px);
}
.product-card {
cursor: pointer;
}
.product-img {
height: 200px;
object-fit: cover;
}
.card-text {
max-height: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
.loading-overlay {
display: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
z-index: 100;
align-items: center;
justify-content: center;
}
.loading-overlay.show {
display: flex;
}
.product-grid-container {
position: relative;
min-height: 300px; /* ローディング中に高さが潰れないように */
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<h5 class="mb-4">商品一覧</h5>
<div id="productGridContainer" class="product-grid-container">
<div class="loading-overlay" id="loadingOverlay">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">読み込み中...</span>
</div>
</div>
<div id="productGrid" class="row g-4">
</div>
</div>
<div class="row mt-4">
<div class="col-12 d-flex justify-content-between align-items-center">
<span id="productCount"></span>
<div class="d-flex align-items-center gap-2 ms-auto">
<label for="pageSizeSelect" class="form-label mb-0">表示件数</label>
<select id="pageSizeSelect" class="form-select form-select-sm" style="width:auto;">
<option value="10">10</option>
<option value="20" selected>20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<nav aria-label="商品ページネーション" class="ms-2">
<ul class="pagination pagination-sm mb-0" id="pagination"></ul>
</nav>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts_extra %}
<script>
const PRODUCT_STATUS_AVAILABLE = 2; // 販売可能ステータス
const CURRENCY_SYMBOL = "¥"; // 通貨記号
const DEFAULT_PAGE_SIZE = 20; // デフォルトの表示件数
const PAGINATION_RANGE = 2; // ページャー表示範囲
let currentPage = 1;
let pageSize = DEFAULT_PAGE_SIZE;
let totalProducts = 0;
let totalPages = 0;
let isLoading = false;
document.addEventListener('DOMContentLoaded', () => {
initialize();
});
function initialize() {
setupEventListeners();
loadProducts(currentPage);
}
function showLoading() {
document.getElementById('loadingOverlay').classList.add('show');
isLoading = true;
}
function hideLoading() {
document.getElementById('loadingOverlay').classList.remove('show');
isLoading = false;
}
function setupEventListeners() {
document.getElementById('pageSizeSelect').addEventListener('change', (e) => {
pageSize = parseInt(e.target.value);
gotoPage(1);
});
}
async function loadProducts(page = 1) {
if (isLoading) return;
showLoading();
try {
const params = new URLSearchParams({
status_id: PRODUCT_STATUS_AVAILABLE,
page: page,
per_page: pageSize
});
const response = await fetch(`/api/products?${params}`);
const data = await response.json();
if (!data || !data.products) {
showError("商品データの取得に失敗しました。");
return;
}
totalProducts = data.total;
totalPages = data.pages;
currentPage = data.page;
renderProducts(data.products);
renderPagination();
updateProductCount();
} catch (error) {
console.error("商品取得エラー:", error);
showError("商品データの取得に失敗しました。");
} finally {
hideLoading();
}
}
function renderProducts(products) {
const grid = document.getElementById("productGrid");
grid.innerHTML = "";
if (!products || products.length === 0) {
grid.innerHTML = `<div class="col-12"><div class="alert alert-warning">現在販売可能な商品はありません。</div></div>`;
return;
}
products.forEach(product => {
const card = document.createElement("div");
card.className = "col-12 col-sm-6 col-md-4 col-lg-3";
card.innerHTML = `
<div class="card h-100 shadow-sm product-card" onclick="location.href='/shop_detail/${product.id}'" style="cursor: pointer;">
<img src="/api/download/product/${product.id}?inline=true"
class="card-img-top product-img"
alt="${escapeHtml(product.name)}">
<div class="card-body d-flex flex-column">
<h5 class="card-title">${escapeHtml(product.name)}</h5>
<p class="fw-bold mt-auto">${CURRENCY_SYMBOL}${product.price.toLocaleString()}</p>
</div>
</div>
`;
grid.appendChild(card);
});
}
function renderPagination() {
const ul = document.getElementById('pagination');
ul.innerHTML = '';
if (totalPages <= 1) return;
const addItem = (label, page, disabled = false, active = false) => {
const li = document.createElement('li');
li.className = `page-item${disabled ? ' disabled' : ''}${active ? ' active' : ''}`;
const a = document.createElement('a');
a.className = 'page-link';
a.href = '#';
a.innerHTML = label; // innerHTMLに変更して « や ‹ を表示
if (!disabled && !active) {
a.addEventListener('click', (e) => {
e.preventDefault();
gotoPage(page);
});
}
li.appendChild(a);
ul.appendChild(li);
};
addItem('«', 1, currentPage === 1);
addItem('‹', Math.max(1, currentPage - 1), currentPage === 1);
let startPage = Math.max(1, currentPage - PAGINATION_RANGE);
let endPage = Math.min(totalPages, currentPage + PAGINATION_RANGE);
if (currentPage <= PAGINATION_RANGE + 1) {
endPage = Math.min(2 * PAGINATION_RANGE + 1, totalPages);
}
if (currentPage > totalPages - (PAGINATION_RANGE + 1)) {
startPage = Math.max(1, totalPages - 2 * PAGINATION_RANGE);
}
if (startPage > 1) addItem('1', 1);
if (startPage > 2) addItem('…', currentPage - (PAGINATION_RANGE + 1), true);
for (let p = startPage; p <= endPage; p++) {
addItem(String(p), p, false, currentPage === p);
}
if (endPage < totalPages - 1) addItem('…', currentPage + (PAGINATION_RANGE + 1), true);
if (endPage < totalPages) addItem(String(totalPages), totalPages);
addItem('›', Math.min(totalPages, currentPage + 1), currentPage === totalPages);
addItem('»', totalPages, currentPage === totalPages);
}
// ▼追加: ページ移動
function gotoPage(page) {
if (isLoading || page < 1 || page > totalPages || page === currentPage) {
return;
}
loadProducts(page);
}
// ▼追加: 商品数表示を更新
function updateProductCount() {
const start = Math.min((currentPage - 1) * pageSize + 1, totalProducts);
const end = Math.min(currentPage * pageSize, totalProducts);
document.getElementById('productCount').textContent =
totalProducts > 0 ? `${totalProducts}件中 ${start}-${end}件を表示` : '現在販売可能な商品はありません。';
}
function showError(message) {
const grid = document.getElementById("productGrid");
grid.innerHTML = `<div class="col-12"><div class="alert alert-danger">${escapeHtml(message)}</div></div>`;
// エラー時はページネーション関連を非表示にする
document.getElementById('productCount').textContent = '';
document.getElementById('pagination').innerHTML = '';
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text ?? "";
return div.innerHTML;
}
</script>
{% endblock %}
```