🙆

講座内課題ECサイト

に公開

- 概要

SIBAKAWA BOOK STOREという既存のECサイトに追加機能を実装していく課題を頑張りました。
最初は個人課題でお問い合わせフォームの作成を行いました。
最後は難しい課題をチーム開発で実施しましたので後述していきます。
エンジニア育成校のeveriGoではアクティブラーニングを採用しています。
課題が配られ自分で調べながらこなし、わからないところがあれば的確な質問をして、講師の方々から答えを引き出すといったスタンスの学校です。

- 使用技術

Laravel / JavaScript / MySQL / HTML / CSS

- Githubリンク

https://github.com/AOaOA07/SHIBAKAWA-BOOK-STORE

- 画面紹介

TOP画面

https://youtu.be/cUMTlL9Nxpc

新刊情報 book/new-release

https://youtu.be/5zUrqRCaBlE

書籍情報 /book

https://youtu.be/Q89nrOm0e6s

書籍詳細 /book/id

https://youtu.be/QHcvJgVeN30

書籍評価 /book/id/review

https://youtu.be/YGnpvRhLTXo

お知らせ /news

https://youtu.be/YIV43_oAbms

実装した追加機能・工夫したところ

星評価機能

https://youtu.be/jtWfzWgiAvs

工夫したところ

<form  class="form" method="post">
                @csrf
                <p class="form-title">星評価</p>
                <div class="form-rating">
                    @for ($i = 5; $i >= 1; $i--)
                        <input class="form-rating__input"
                               id="star{{ $i }}"
                               name="rating"
                               type="radio"
                               value="{{ $i }}"
                               {{ (old('rating', $book->reviews()->where('user_id', auth()->id())->first()->rating ?? 0) == $i) ? 'checked' : '' }}>
                        <label class="form-rating__label" for="star{{ $i }}">
                            <span class="star">&#9733;</span>
                        </label>
                    @endfor
                </div>
                @error('rating')
                <span class="error">{{ $message }}</span><br>
                @enderror
                @error('title')
                <span class="error">{{ $message }}</span><br>
                @enderror
                @error('content')
                <span class="error">{{ $message }}</span><br>
                @enderror
                <div class="form-group">
                    <label for="title">タイトル</label>
                    <input type="text" name="title" id="title" value="{{ old('title', $book->reviews()->where('user_id', auth()->id())->first()->title ?? '') }}" required>
                </div>
                <div class="form-group">
                    <label for="content">レビュー内容</label>
                    <textarea name="content" id="content" required>{{ old('content', $book->reviews()->where('user_id', auth()->id())->first()->content ?? '') }}</textarea>
                </div>
                <ul>
                    <li><button formaction="{{ route('book.sent', ['id' => $book->id]) }}" type="submit" class="review-post-button">投稿する</button></li>
                    <li><button formaction="{{ route('book.deleted', ['id' => $book->id]) }}"  class="review-deleted-button">削除する</button></li>
                </ul>
            </form>

old関数で以前の入力内容や保存済みレビューを反映しました。
<button formaction="..."> を使い、同じフォームから「投稿する」と「削除する」を切り替えられるようにしました。

ページネーション

https://youtu.be/oJhJ2Fd3JlE

星評価のグラフ

https://youtu.be/WgUO31NgF9E

工夫したところ

document.addEventListener('DOMContentLoaded', function () {
    // #ratingChartが存在するか確認
    const ratingChartElement = document.getElementById('ratingChart')

    // #ratingChartが見つからない場合、エラーメッセージを表示して処理を終了
    if (!ratingChartElement) {
        return // 処理をここで停止
    }

    // ここから先は#ratingChartが存在する場合のみ実行されます
    const currentUrl = ratingChartElement.dataset.currentUrl

    // データ取得
    const labels = ['星5つ', '星4つ', '星3つ', '星2つ', '星1つ']
    const data = JSON.parse(ratingChartElement.dataset.starCounts)
    const totalReviews = data.reduce((sum, value) => sum + value, 0)
    const percentages = data.map((value) =>
        Math.round((value / totalReviews) * 100)
    )

    // カスタムプラグインでパーセンテージ表示
    const barPercentagePlugin = {
        id: 'barPercentagePlugin',
        afterDatasetsDraw(chart) {
            const {
                ctx,
                chartArea,
                scales: { x, y },
            } = chart
            const chartWidth = chartArea.right

            chart.data.datasets[0].data.forEach((value, index) => {
                const percentageText = `${percentages[index]}%`
                let xPosition = x.getPixelForValue(value)
                const yPosition = y.getPixelForValue(index)

                const textWidth = ctx.measureText(percentageText).width
                if (xPosition + textWidth + 10 > chartWidth) {
                    xPosition = chartWidth - textWidth - 10
                }

                ctx.save()
                ctx.font = '14px Arial'
                ctx.fillStyle = 'black'
                ctx.textAlign = 'left'
                ctx.textBaseline = 'middle'
                ctx.fillText(percentageText, xPosition + 5, yPosition)
                ctx.restore()
            })
        },
    }

    const maxValue = Math.max(...data)

    // グラフ設定
    const config = {
        type: 'bar',
        data: {
            labels: labels,
            datasets: [
                {
                    label: '評価の数',
                    data: data,
                    backgroundColor: [
                        'rgba(255, 204, 0, 0.5)',
                        'rgba(255, 204, 0, 0.5)',
                        'rgba(255, 204, 0, 0.5)',
                        'rgba(255, 204, 0, 0.5)',
                        'rgba(255, 204, 0, 0.5)',
                    ],
                    borderColor: '',
                    borderWidth: 0,
                    hoverBackgroundColor: 'rgba(255, 204, 0, 1)',
                },
            ],
        },
        options: {
            indexAxis: 'y',
            scales: {
                x: {
                    beginAtZero: true,
                    grid: {
                        display: false, // x軸のグリッド線を非表示
                    },
                    ticks: {
                        display: false, // x軸のラベルを非表示
                    },
                    max: maxValue * 1.15,
                },
                y: {
                    grid: {
                        display: false, // y軸のグリッド線を非表示
                    },
                    ticks: {
                        font: {
                            family: "'Yu Gothic Medium', '游ゴシック Medium', YuGothic, '游ゴシック体', 'ヒラギノ角ゴ Pro W3', 'メイリオ', sans-serif",
                            size: 14,
                        },
                        color: 'black',
                    }
                },
            },
            plugins: {
                tooltip: {
                    enabled: false, // ツールチップを非表示
                },
                title: {
                    display: true,
                    text: `${totalReviews}件の評価`,
                    font: {
                        family: "'Yu Gothic Medium', '游ゴシック Medium', YuGothic, '游ゴシック体', 'ヒラギノ角ゴ Pro W3', 'メイリオ', sans-serif",  // フォントファミリー
                        size: 16,
                        weight: 'normal',
                    },
                    color: 'black',
                    padding: {
                        top: 10,
                        bottom: 10,
                    },
                },
                legend: {
                    display: false,
                },
            },
            onClick: (event, chartElement) => {
                if (chartElement.length) {
                    const dataIndex = chartElement[0].index
                    const star = 5 - dataIndex

                    // 現在のURLオブジェクトを作成
                    const url = new URL(window.location.href)

                    // `star` パラメータを更新
                    url.searchParams.set('star', star)

                    // 既存の `order_rule` を維持
                    const orderRule = url.searchParams.get('order_rule')
                    if (orderRule) {
                        url.searchParams.set('order_rule', orderRule)
                    }

                    // ハッシュを `review-section` に設定
                    url.hash = 'review-section'

                    // 新しいURLに遷移
                    window.location.href = url.toString()
                }
            },
            onHover: (event, chartElement) => {
                const canvas = event.native.target
                canvas.style.cursor = chartElement.length
                    ? 'pointer'
                    : 'default'
            },
        },
        plugins: [barPercentagePlugin],
    }

    // グラフのレンダリング
    const ctx = ratingChartElement.getContext('2d')
    new Chart(ctx, config)
})

chart.jsを使いグラフを実装しました。

good、bad機能

https://youtu.be/eKyTKzDOMvU

工夫したところ

document.addEventListener('DOMContentLoaded', function () {
    // 初期状態を設定
    document.querySelectorAll('.reaction-button').forEach((button) => {
        const reviewId = button.dataset.reviewId

        fetch(`/review/${reviewId}/reaction-status`, {
            method: 'GET',
            headers: {
                'Content-Type': 'application/json',
            },
        })
            .then((response) => response.json())
            .then((data) => {
                if (data.reaction !== null) {
                    const goodButton = document.querySelector(
                        `button[data-review-id="${reviewId}"].good`
                    )
                    const badButton = document.querySelector(
                        `button[data-review-id="${reviewId}"].bad`
                    )

                    if (data.reaction === 0) {
                        goodButton.classList.add('active')
                        badButton.classList.remove('active')
                    } else if (data.reaction === 1) {
                        badButton.classList.add('active')
                        goodButton.classList.remove('active')
                    }
                }
            })
            .catch((error) => {
                console.error('Error:', error)
            })
    })

    // ボタンのクリックイベントを設定
    document.querySelectorAll('.reaction-button').forEach((button) => {
        button.addEventListener('click', function () {
            const reviewId = this.dataset.reviewId
            const reaction = parseInt(this.dataset.reaction, 10)
            const bookId = this.closest('.review-item')?.dataset.bookId

            if (!bookId) {
                console.error('Error: bookId not found.')
                return
            }

            const goodButton = document.querySelector(
                `button[data-review-id="${reviewId}"].good`
            )
            const badButton = document.querySelector(
                `button[data-review-id="${reviewId}"].bad`
            )
            let newReaction = reaction

            // すでに good/bad が押されている場合、リアクションを取り消し
            if (
                (reaction === 0 && goodButton.classList.contains('active')) ||
                (reaction === 1 && badButton.classList.contains('active'))
            ) {
                newReaction = null // null を送信してリアクションを削除
            }

            fetch(`/book/${bookId}/review/reaction`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector(
                        'meta[name="csrf-token"]'
                    ).content,
                },
                body: JSON.stringify({
                    review_id: reviewId,
                    reaction: newReaction,
                }),
            })
                .then((response) => response.json())
                .then((data) => {
                    if (data.error) {
                        console.error('Server Error:', data.error)
                        return
                    }

                    document.querySelector(
                        `button[data-review-id="${reviewId}"].good .good-count`
                    ).textContent = data.good_count
                    document.querySelector(
                        `button[data-review-id="${reviewId}"].bad .bad-count`
                    ).textContent = data.bad_count

                    // リアクションの状態を更新
                    if (data.reaction_state === null) {
                        // リアクションが取り消された場合
                        goodButton.classList.remove('active')
                        badButton.classList.remove('active')
                    } else if (data.reaction_state === 0) {
                        // good が押された場合
                        goodButton.classList.add('active')
                        badButton.classList.remove('active')
                    } else {
                        // bad が押された場合
                        badButton.classList.add('active')
                        goodButton.classList.remove('active')
                    }
                })
                .catch((error) => {
                    console.error('Error:', error)
                })
        })
    })
})

Ajax(非同期通信)を使いgood、bad機能を実装しました。

レビューの表示順の並び替えと表示件数の変更

https://youtu.be/sk18COa-RZs

工夫したところ

view

<select id="order_rule">
                                @php
                                    $order_rules = [
                                        'updated_date_desc' => '新しい順',
                                        'updated_date_asc' => '古い順',
                                        'reactions_desc' => 'リアクションの多い順',
                                        'reactions_asc' => 'リアクションの少ない順',
                                    ];
                                @endphp
                                @foreach ($order_rules as $key => $name)
                                <option value="{{ $key }}" {{ $order_rule == $key ? 'selected' : '' }}>
                                    {{ $name }}
                                </option>
                                @endforeach
                            </select>
BookRepository

// 並び替え処理
        switch ($order_rule) {
            case 'updated_date_asc':
                $reviews->orderBy('reviews.updated_at', 'asc');
                break;
            case 'updated_date_desc':
                $reviews->orderBy('reviews.updated_at', 'desc');
                break;
            case 'reactions_asc':
                $reviews->orderBy('reaction_count', 'asc');
                break;
            case 'reactions_desc':
                $reviews->orderBy('reaction_count', 'desc');
                break;
            default:
                $reviews->orderBy('reviews.updated_at', 'desc');
                break;
        }

viewのプルダウンメニューに対応した並び替え処理をbookリポジトリにて実装しました。
動画ではプルダウンメニューが写っていませんがしっかりと表示されています。

Discussion