├── .dockerignore
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .node-version
├── .npmrc
├── .prettierignore
├── .prettierrc.js
├── Dockerfile
├── LICENSE
├── README.md
├── databases
└── database.seed.sqlite
├── fly.toml
├── index.html
├── package.json
├── pnpm-lock.yaml
├── public
├── fonts
│ ├── NotoSerifJP-Bold.otf
│ └── NotoSerifJP-Regular.otf
├── icons
│ └── logo.svg
├── images
│ ├── avatars
│ │ ├── 001.jpg
│ │ ├── 002.jpg
│ │ ├── 003.jpg
│ │ ├── 004.jpg
│ │ ├── 005.jpg
│ │ ├── 006.jpg
│ │ ├── 007.jpg
│ │ ├── 008.jpg
│ │ ├── 009.jpg
│ │ ├── 010.jpg
│ │ ├── 011.jpg
│ │ ├── 012.jpg
│ │ ├── 013.jpg
│ │ ├── 014.jpg
│ │ ├── 015.jpg
│ │ ├── 016.jpg
│ │ ├── 017.jpg
│ │ ├── 018.jpg
│ │ ├── 019.jpg
│ │ └── 020.jpg
│ └── products
│ │ ├── apple
│ │ ├── 001.jpg
│ │ ├── 002.jpg
│ │ ├── 003.jpg
│ │ ├── 004.jpg
│ │ ├── 005.jpg
│ │ ├── 006.jpg
│ │ └── 007.jpg
│ │ ├── banana
│ │ ├── 001.jpg
│ │ ├── 002.jpg
│ │ ├── 003.jpg
│ │ ├── 004.jpg
│ │ ├── 005.jpg
│ │ ├── 006.jpg
│ │ ├── 007.jpg
│ │ └── 008.jpg
│ │ ├── blueberry
│ │ ├── 001.jpg
│ │ ├── 002.jpg
│ │ ├── 003.jpg
│ │ ├── 004.jpg
│ │ ├── 005.jpg
│ │ ├── 006.jpg
│ │ ├── 007.jpg
│ │ ├── 008.jpg
│ │ └── 009.jpg
│ │ ├── cabbage
│ │ ├── 001.jpg
│ │ ├── 002.jpg
│ │ ├── 003.jpg
│ │ ├── 004.jpg
│ │ ├── 005.jpg
│ │ ├── 006.jpg
│ │ └── 007.jpg
│ │ ├── carrot
│ │ ├── 001.jpg
│ │ ├── 002.jpg
│ │ ├── 003.jpg
│ │ ├── 004.jpg
│ │ ├── 005.jpg
│ │ └── 006.jpg
│ │ ├── strawberry
│ │ ├── 001.jpg
│ │ ├── 002.jpg
│ │ ├── 003.jpg
│ │ ├── 004.jpg
│ │ └── 005.jpg
│ │ └── tomato
│ │ ├── 001.jpg
│ │ ├── 002.jpg
│ │ ├── 003.jpg
│ │ ├── 004.jpg
│ │ ├── 005.jpg
│ │ ├── 006.jpg
│ │ ├── 007.jpg
│ │ ├── 008.jpg
│ │ ├── 009.jpg
│ │ └── 010.jpg
├── robots.txt
└── videos
│ ├── 001.mp4
│ ├── 002.mp4
│ └── 003.mp4
├── src
├── client
│ ├── components
│ │ ├── application
│ │ │ ├── App
│ │ │ │ ├── App.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Layout
│ │ │ │ ├── Layout.styles.ts
│ │ │ │ ├── Layout.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Providers
│ │ │ │ ├── Providers.tsx
│ │ │ │ └── index.ts
│ │ │ └── Routes
│ │ │ │ ├── Routes.tsx
│ │ │ │ ├── hooks
│ │ │ │ ├── index.ts
│ │ │ │ └── useScrollToTop.ts
│ │ │ │ └── index.ts
│ │ ├── feature
│ │ │ ├── ProductCard
│ │ │ │ ├── ProductCard.styles.ts
│ │ │ │ ├── ProductCard.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ProductGridList
│ │ │ │ ├── ProductGridList.styles.ts
│ │ │ │ ├── ProductGridList.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ProductList
│ │ │ │ ├── ProductList.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ProductListSlideButton
│ │ │ │ ├── ProductListSlideButton.styles.ts
│ │ │ │ ├── ProductListSlideButton.tsx
│ │ │ │ └── index.ts
│ │ │ └── ProductListSlider
│ │ │ │ ├── ProductListSlider.styles.ts
│ │ │ │ ├── ProductListSlider.tsx
│ │ │ │ ├── hooks
│ │ │ │ └── useSlider.ts
│ │ │ │ └── index.ts
│ │ ├── foundation
│ │ │ ├── Anchor
│ │ │ │ ├── Anchor.styles.ts
│ │ │ │ ├── Anchor.tsx
│ │ │ │ └── index.ts
│ │ │ ├── AspectRatio
│ │ │ │ ├── AspectRatio.styles.ts
│ │ │ │ ├── AspectRatio.tsx
│ │ │ │ └── index.ts
│ │ │ ├── GetDeviceType
│ │ │ │ ├── GetDeviceType.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Icon
│ │ │ │ ├── Icon.styles.ts
│ │ │ │ ├── Icon.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Image
│ │ │ │ ├── Image.styles.ts
│ │ │ │ ├── Image.tsx
│ │ │ │ └── index.ts
│ │ │ ├── Modal
│ │ │ │ ├── Modal.styles.ts
│ │ │ │ ├── Modal.tsx
│ │ │ │ └── index.ts
│ │ │ ├── OutlineButton
│ │ │ │ ├── OutlineButton.styles.ts
│ │ │ │ ├── OutlineButton.tsx
│ │ │ │ └── index.ts
│ │ │ ├── PrimaryAnchor
│ │ │ │ ├── PrimaryAnchor.styles.ts
│ │ │ │ ├── PrimaryAnchor.tsx
│ │ │ │ └── index.ts
│ │ │ ├── PrimaryButton
│ │ │ │ ├── PrimaryButton.styles.ts
│ │ │ │ ├── PrimaryButton.tsx
│ │ │ │ └── index.ts
│ │ │ ├── TextArea
│ │ │ │ ├── TextArea.styles.ts
│ │ │ │ ├── TextArea.tsx
│ │ │ │ └── index.ts
│ │ │ ├── TextInput
│ │ │ │ ├── TextInput.styles.ts
│ │ │ │ ├── TextInput.tsx
│ │ │ │ └── index.ts
│ │ │ └── WidthRestriction
│ │ │ │ ├── WidthRestriction.styles.ts
│ │ │ │ ├── WidthRestriction.tsx
│ │ │ │ └── index.ts
│ │ ├── modal
│ │ │ ├── SignInModal
│ │ │ │ ├── SignInModal.styles.ts
│ │ │ │ ├── SignInModal.tsx
│ │ │ │ └── index.ts
│ │ │ └── SignUpModal
│ │ │ │ ├── SignUpModal.styles.ts
│ │ │ │ ├── SignUpModal.tsx
│ │ │ │ └── index.ts
│ │ ├── navigators
│ │ │ ├── Footer
│ │ │ │ ├── Footer.styles.ts
│ │ │ │ ├── Footer.tsx
│ │ │ │ └── index.ts
│ │ │ └── Header
│ │ │ │ ├── Header.styles.ts
│ │ │ │ ├── Header.tsx
│ │ │ │ └── index.ts
│ │ ├── order
│ │ │ ├── CartItem
│ │ │ │ ├── CartItem.styles.ts
│ │ │ │ ├── CartItem.tsx
│ │ │ │ └── index.ts
│ │ │ ├── OrderForm
│ │ │ │ ├── OrderForm.styles.ts
│ │ │ │ ├── OrderForm.tsx
│ │ │ │ └── index.ts
│ │ │ └── OrderPreview
│ │ │ │ ├── OrderPreview.styles.ts
│ │ │ │ ├── OrderPreview.tsx
│ │ │ │ └── index.ts
│ │ ├── product
│ │ │ ├── ProductHeroImage
│ │ │ │ ├── ProductHeroImage.styles.ts
│ │ │ │ ├── ProductHeroImage.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ProductMediaListPreviewer
│ │ │ │ ├── MediaItem
│ │ │ │ │ ├── MediaItem.styles.ts
│ │ │ │ │ ├── MediaItem.tsx
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── loadThumbnail.ts
│ │ │ │ ├── MediaItemPreviewer
│ │ │ │ │ ├── MediaItemPreiewer.styles.ts
│ │ │ │ │ ├── MediaItemPreviewer.tsx
│ │ │ │ │ └── index.ts
│ │ │ │ ├── ProductMediaListPreviewer.styles.ts
│ │ │ │ ├── ProductMediaListPreviewer.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ProductOfferLabel
│ │ │ │ ├── ProductOfferLabel.styles.ts
│ │ │ │ ├── ProductOfferLabel.tsx
│ │ │ │ └── index.ts
│ │ │ ├── ProductOverview
│ │ │ │ ├── ProductOverview.styles.ts
│ │ │ │ ├── ProductOverview.tsx
│ │ │ │ └── index.ts
│ │ │ └── ProductPurchaseSeciton
│ │ │ │ ├── ProductPurchaseSection.styles.ts
│ │ │ │ ├── ProductPurchaseSection.tsx
│ │ │ │ └── index.ts
│ │ └── review
│ │ │ ├── ReviewList
│ │ │ ├── ReviewList.styles.ts
│ │ │ ├── ReviewList.tsx
│ │ │ └── index.ts
│ │ │ └── ReviewSection
│ │ │ ├── ReviewSection.styles.ts
│ │ │ ├── ReviewSection.tsx
│ │ │ └── index.ts
│ ├── global.styles.ts
│ ├── graphql
│ │ ├── fragments.ts
│ │ ├── mutations.ts
│ │ └── queries.ts
│ ├── hooks
│ │ ├── useActiveOffer.ts
│ │ ├── useAmountInCart.ts
│ │ ├── useAuthUser.ts
│ │ ├── useFeatures.ts
│ │ ├── useOrder.ts
│ │ ├── useProduct.ts
│ │ ├── useRecommendation.ts
│ │ ├── useReviews.ts
│ │ ├── useSendReview.ts
│ │ ├── useSignIn.ts
│ │ ├── useSignUp.ts
│ │ ├── useSubmitOrder.ts
│ │ ├── useTotalPrice.ts
│ │ └── useUpdateCartItems.ts
│ ├── index.tsx
│ ├── pages
│ │ ├── Fallback
│ │ │ ├── Fallback.styles.ts
│ │ │ ├── Fallback.tsx
│ │ │ └── index.ts
│ │ ├── NotFound
│ │ │ ├── NotFound.styles.ts
│ │ │ ├── NotFound.tsx
│ │ │ └── index.ts
│ │ ├── Order
│ │ │ ├── Order.styles.ts
│ │ │ ├── Order.tsx
│ │ │ └── index.ts
│ │ ├── OrderComplete
│ │ │ ├── OrderComplete.styles.ts
│ │ │ ├── OrderComplete.tsx
│ │ │ └── index.ts
│ │ ├── ProductDetail
│ │ │ ├── ProductDetail.styles.ts
│ │ │ ├── ProductDetail.tsx
│ │ │ └── index.ts
│ │ └── Top
│ │ │ ├── Top.styles.ts
│ │ │ ├── Top.tsx
│ │ │ └── index.ts
│ ├── polyfill
│ │ ├── install.ts
│ │ └── temporal.ts
│ ├── store
│ │ └── modal
│ │ │ ├── hooks.ts
│ │ │ ├── index.ts
│ │ │ └── state.ts
│ ├── types
│ │ └── zipcode-ja.d.ts
│ ├── utils
│ │ ├── apollo_client.ts
│ │ ├── get_active_offer.ts
│ │ ├── get_media_type.ts
│ │ ├── load_fonts.ts
│ │ └── normalize_cart_item.ts
│ └── vite-env.d.ts
├── model
│ ├── feature_item.graphql
│ ├── feature_item.ts
│ ├── feature_section.graphql
│ ├── feature_section.ts
│ ├── limited_time_offer.graphql
│ ├── limited_time_offer.ts
│ ├── media_file.graphql
│ ├── media_file.ts
│ ├── order.graphql
│ ├── order.ts
│ ├── product.graphql
│ ├── product.ts
│ ├── product_media.graphql
│ ├── product_media.ts
│ ├── profile.graphql
│ ├── profile.ts
│ ├── recommendation.graphql
│ ├── recommendation.ts
│ ├── review.graphql
│ ├── review.ts
│ ├── shopping_cart_item.graphql
│ ├── shopping_cart_item.ts
│ ├── user.graphql
│ └── user.ts
└── server
│ ├── context.ts
│ ├── data_source.ts
│ ├── graphql
│ ├── feature_item_resolver.ts
│ ├── feature_section_resolver.ts
│ ├── index.ts
│ ├── model_resolver.ts
│ ├── mutation.graphql
│ ├── mutation_resolver.ts
│ ├── order_resolver.ts
│ ├── product_media_resolver.ts
│ ├── product_resolver.ts
│ ├── profile_resolver.ts
│ ├── query.graphql
│ ├── query_resolver.ts
│ ├── recommendation_resolver.ts
│ ├── review_resolver.ts
│ ├── shopping_cart_item_resolver.ts
│ └── user_resolver.ts
│ ├── index.ts
│ └── utils
│ ├── database_paths.ts
│ ├── initialize_database.ts
│ └── root_resolve.ts
├── stylelint.config.js
├── tools
├── aozora.ts
├── get_file_list.ts
└── seed.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.dockerignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
3 | databases/database.sqlite
4 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist
2 | __generated__
3 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | browser: true,
4 | es2022: true,
5 | node: true,
6 | },
7 | extends: [
8 | 'eslint:recommended',
9 | 'plugin:import/recommended',
10 | 'plugin:react/recommended',
11 | 'plugin:react-hooks/recommended',
12 | 'plugin:sort/recommended',
13 | 'plugin:@typescript-eslint/recommended',
14 | 'prettier',
15 | ],
16 | parser: '@typescript-eslint/parser',
17 | parserOptions: {
18 | ecmaVersion: 2022,
19 | sourceType: 'module',
20 | },
21 | plugins: ['import', 'sort', '@typescript-eslint'],
22 | rules: {
23 | '@typescript-eslint/consistent-type-definitions': ['error', 'type'],
24 | '@typescript-eslint/consistent-type-imports': ['error'],
25 | '@typescript-eslint/no-unused-vars': [
26 | 'error',
27 | {
28 | argsIgnorePattern: '^_',
29 | },
30 | ],
31 | 'import/namespace': ['off'],
32 | 'import/order': [
33 | 'error',
34 | {
35 | alphabetize: {
36 | order: 'asc',
37 | },
38 | 'newlines-between': 'always',
39 | },
40 | ],
41 | 'react/jsx-sort-props': [
42 | 'error',
43 | {
44 | reservedFirst: true,
45 | shorthandFirst: true,
46 | },
47 | ],
48 | 'react/prop-types': ['off'],
49 | 'react/react-in-jsx-scope': ['off'],
50 | 'sort/imports': ['off'],
51 | },
52 | settings: {
53 | 'import/parsers': {
54 | '@typescript-eslint/parser': ['.ts', '.cts', '.mts', '.tsx'],
55 | },
56 | 'import/resolver': {
57 | typescript: {},
58 | },
59 | react: {
60 | version: 'detect',
61 | },
62 | },
63 | };
64 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/node,windows,linux,macos,visualstudiocode
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node,windows,linux,macos,visualstudiocode
3 |
4 | ### Linux ###
5 | *~
6 |
7 | # temporary files which can be created if a process still has a handle open of a deleted file
8 | .fuse_hidden*
9 |
10 | # KDE directory preferences
11 | .directory
12 |
13 | # Linux trash folder which might appear on any partition or disk
14 | .Trash-*
15 |
16 | # .nfs files are created when an open file is removed but is still being accessed
17 | .nfs*
18 |
19 | ### macOS ###
20 | # General
21 | .DS_Store
22 | .AppleDouble
23 | .LSOverride
24 |
25 | # Thumbnails
26 | ._*
27 |
28 | # Files that might appear in the root of a volume
29 | .DocumentRevisions-V100
30 | .fseventsd
31 | .Spotlight-V100
32 | .TemporaryItems
33 | .Trashes
34 | .VolumeIcon.icns
35 | .com.apple.timemachine.donotpresent
36 |
37 | # Directories potentially created on remote AFP share
38 | .AppleDB
39 | .AppleDesktop
40 | Network Trash Folder
41 | Temporary Items
42 | .apdisk
43 |
44 | ### macOS Patch ###
45 | # iCloud generated files
46 | *.icloud
47 |
48 | ### Node ###
49 | # Logs
50 | logs
51 | *.log
52 | npm-debug.log*
53 | yarn-debug.log*
54 | yarn-error.log*
55 | lerna-debug.log*
56 | .pnpm-debug.log*
57 |
58 | # Diagnostic reports (https://nodejs.org/api/report.html)
59 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
60 |
61 | # Runtime data
62 | pids
63 | *.pid
64 | *.seed
65 | *.pid.lock
66 |
67 | # Directory for instrumented libs generated by jscoverage/JSCover
68 | lib-cov
69 |
70 | # Coverage directory used by tools like istanbul
71 | coverage
72 | *.lcov
73 |
74 | # nyc test coverage
75 | .nyc_output
76 |
77 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
78 | .grunt
79 |
80 | # Bower dependency directory (https://bower.io/)
81 | bower_components
82 |
83 | # node-waf configuration
84 | .lock-wscript
85 |
86 | # Compiled binary addons (https://nodejs.org/api/addons.html)
87 | build/Release
88 |
89 | # Dependency directories
90 | node_modules/
91 | jspm_packages/
92 |
93 | # Snowpack dependency directory (https://snowpack.dev/)
94 | web_modules/
95 |
96 | # TypeScript cache
97 | *.tsbuildinfo
98 |
99 | # Optional npm cache directory
100 | .npm
101 |
102 | # Optional eslint cache
103 | .eslintcache
104 |
105 | # Optional stylelint cache
106 | .stylelintcache
107 |
108 | # Microbundle cache
109 | .rpt2_cache/
110 | .rts2_cache_cjs/
111 | .rts2_cache_es/
112 | .rts2_cache_umd/
113 |
114 | # Optional REPL history
115 | .node_repl_history
116 |
117 | # Output of 'npm pack'
118 | *.tgz
119 |
120 | # Yarn Integrity file
121 | .yarn-integrity
122 |
123 | # dotenv environment variable files
124 | .env
125 | .env.development.local
126 | .env.test.local
127 | .env.production.local
128 | .env.local
129 |
130 | # parcel-bundler cache (https://parceljs.org/)
131 | .cache
132 | .parcel-cache
133 |
134 | # Next.js build output
135 | .next
136 | out
137 |
138 | # Nuxt.js build / generate output
139 | .nuxt
140 | dist
141 |
142 | # Gatsby files
143 | .cache/
144 | # Comment in the public line in if your project uses Gatsby and not Next.js
145 | # https://nextjs.org/blog/next-9-1#public-directory-support
146 | # public
147 |
148 | # vuepress build output
149 | .vuepress/dist
150 |
151 | # vuepress v2.x temp and cache directory
152 | .temp
153 |
154 | # Docusaurus cache and generated files
155 | .docusaurus
156 |
157 | # Serverless directories
158 | .serverless/
159 |
160 | # FuseBox cache
161 | .fusebox/
162 |
163 | # DynamoDB Local files
164 | .dynamodb/
165 |
166 | # TernJS port file
167 | .tern-port
168 |
169 | # Stores VSCode versions used for testing VSCode extensions
170 | .vscode-test
171 |
172 | # yarn v2
173 | .yarn/cache
174 | .yarn/unplugged
175 | .yarn/build-state.yml
176 | .yarn/install-state.gz
177 | .pnp.*
178 |
179 | ### Node Patch ###
180 | # Serverless Webpack directories
181 | .webpack/
182 |
183 | # Optional stylelint cache
184 |
185 | # SvelteKit build / generate output
186 | .svelte-kit
187 |
188 | ### VisualStudioCode ###
189 | .vscode/*
190 |
191 | # Local History for Visual Studio Code
192 | .history/
193 |
194 | # Built Visual Studio Code Extensions
195 | *.vsix
196 |
197 | ### VisualStudioCode Patch ###
198 | # Ignore all local history of files
199 | .history
200 | .ionide
201 |
202 | ### Windows ###
203 | # Windows thumbnail cache files
204 | Thumbs.db
205 | Thumbs.db:encryptable
206 | ehthumbs.db
207 | ehthumbs_vista.db
208 |
209 | # Dump file
210 | *.stackdump
211 |
212 | # Folder config file
213 | [Dd]esktop.ini
214 |
215 | # Recycle Bin used on file shares
216 | $RECYCLE.BIN/
217 |
218 | # Windows Installer files
219 | *.cab
220 | *.msi
221 | *.msix
222 | *.msm
223 | *.msp
224 |
225 | # Windows shortcuts
226 | *.lnk
227 |
228 | # End of https://www.toptal.com/developers/gitignore/api/node,windows,linux,macos,visualstudiocode
229 |
230 | databases/database.sqlite
231 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 18.13.0
2 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | save-exact=true
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | __generated__
3 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | arrowParens: 'always',
3 | overrides: [
4 | {
5 | files: ['package.json'],
6 | options: {
7 | plugins: [require.resolve('prettier-plugin-packagejson')],
8 | },
9 | },
10 | {
11 | excludeFiles: ['package.json'],
12 | files: ['*.json'],
13 | options: {
14 | jsonRecursiveSort: true,
15 | plugins: [require.resolve('prettier-plugin-sort-json')],
16 | },
17 | },
18 | ],
19 | plugins: [],
20 | printWidth: 120,
21 | singleQuote: true,
22 | trailingComma: 'all',
23 | };
24 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:18.13.0-bullseye AS build
2 | ENV TZ Asia/Tokyo
3 | ENV NODE_ENV development
4 |
5 | RUN apt-get update && apt-get install -y --no-install-recommends dumb-init sqlite3
6 | RUN npm install -g pnpm
7 | RUN mkdir /app
8 | WORKDIR /app
9 | COPY package.json pnpm-lock.yaml tsconfig.json tsconfig.node.json vite.config.ts index.html .npmrc /app/
10 | COPY databases/ /app/databases/
11 | COPY public/ /app/public/
12 | COPY tools/ /app/tools/
13 | COPY src/ /app/src/
14 | RUN pnpm install
15 | RUN pnpm build
16 |
17 | ########################################################################
18 |
19 | FROM node:18.13.0-bullseye-slim
20 | ENV TZ Asia/Tokyo
21 | ENV NODE_ENV development
22 |
23 | COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
24 | COPY --from=build /usr/bin/sqlite3 /usr/bin/sqlite3
25 | COPY --from=build --chown=node:node /app /app
26 | WORKDIR /app
27 | USER node
28 | CMD ["dumb-init", "./node_modules/.bin/ts-node", "./src/server/index.ts"]
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Web Speed Hackathon 2023
2 |
3 | ## 概要
4 |
5 | **"Web Speed Hackathon 2023" は、非常に重たい Web アプリをチューニングして、いかに高速にするかを競う競技です。**
6 |
7 | - 募集要項: https://cyberagent.connpass.com/event/270424
8 |
9 | ## 課題
10 |
11 | 今回のテーマは、架空のショッピングサイト「買えるオーガニック」です。
12 | 後述のレギュレーションを守った上で、買えるオーガニック のパフォーマンスを改善してください。
13 |
14 | - デモサイト: https://web-speed-hackathon-2023.fly.dev
15 | - リーダーボード (順位表): https://web-speed-hackathon-scoring-server-2023.fly.dev
16 |
17 | ## 提出方法
18 |
19 | 評価対象となる環境(URL)を作成し、以下のレポジトリから参加登録を行なってください。
20 |
21 | https://github.com/CyberAgentHack/web-speed-hackathon-2023-scoring-tool
22 |
23 | ## デプロイ
24 |
25 | 提出用環境の作成は、以下のいずれかの手順でローカルのアプリケーションをデプロイすることで行えます。
26 |
27 | ### Fly.io へデプロイする場合
28 |
29 | 1. このレポジトリを自分のレポジトリに fork します
30 | - https://docs.github.com/ja/github/getting-started-with-github/fork-a-repo
31 | 1. 下記手順などに従い Fly.io へデプロイの設定を行います
32 | - https://fly.io/docs/hands-on/install-flyctl
33 | 1. `flyctl launch`コマンドで新規アプリケーションの設定を行います
34 | - 既にコミットしてある fly.toml ファイルを利用すると必要な設定を省くことができます
35 | - 実行途中に表示される Postgresql と Redis のセットアップに関しては行う必要はありません
36 | 1. 以降、`flyctl deploy`コマンドでデプロイを行うことができます
37 |
38 | ※ Github アカウントを紐づけて Fly.io のアカウントを新規作成すると、クレジットカードの登録なしで無料枠を利用することができます
39 |
40 | ### Fly.io 外へデプロイする場合
41 |
42 | - 無料の範囲内であれば、Fly.io 以外へデプロイしてもかまいません
43 | - **外部のサービスは全て無料枠の範囲内で使用してください。万が一コストが発生した場合は、全て自己負担となります。**
44 | - Fly.io 外へのデプロイについて、運営からサポートしません
45 | - デプロイ方法がわからない方は Fly.io で立ち上げることをオススメします
46 |
47 | ## 採点
48 |
49 | 採点は GitHub Actions を用いて、参加登録がされた時点および参加者が採点を要求した任意の時点で行われます。
50 |
51 | 採点の詳細についてはこちらに記載しています
52 |
53 | https://github.com/CyberAgentHack/web-speed-hackathon-2023-scoring-tool/blob/main/docs/SCORING.md
54 |
55 | ## レギュレーション
56 |
57 | レギュレーションに違反した場合には、順位対象外となります。
58 |
59 | レギュレーションの詳細についてはこちらに記載しています
60 |
61 | https://github.com/CyberAgentHack/web-speed-hackathon-2023-scoring-tool/blob/main/docs/REGULATION.md
62 |
63 | ### 上位にランクインしたアプリケーションについて
64 |
65 | 競技終了後、リーダーボードで上位にランクインしたアプリケーションをレギュレーションに抵触していないか運営が確認します。
66 | 確認にはチェックリストに基づいて運営が手作業で確認を行います
67 |
68 | チェックリストの詳細についてはこちらに記載しています
69 |
70 | https://github.com/CyberAgentHack/web-speed-hackathon-2023-scoring-tool/blob/main/docs/CHECKLIST.md
71 |
72 | ## 開発方法
73 |
74 | ### 環境
75 |
76 | - Node.js (v18 以上)
77 | - pnpm
78 |
79 | ### コマンド
80 |
81 | 最低限のコマンドだけ記載します。
82 | それ以外については、各フォルダの `package.json` を参照してください。
83 |
84 | #### 準備
85 |
86 | ```bash
87 | pnpm install
88 | ```
89 |
90 | #### ビルド
91 |
92 | ```bash
93 | pnpm build
94 | ```
95 |
96 | #### 開発環境の起動
97 |
98 | ファイル変更時にクライアント・サーバー両方のビルドと再起動が自動で行われます。
99 | **ホットリロードはありません**ので、変更をブラウザで確認するには変更後にリロードしてください。
100 |
101 | 標準では `http://localhost:8080` でアクセスできます。
102 |
103 | ```bash
104 | pnpm start
105 | ```
106 |
107 | ## ライセンス
108 |
109 | - Code: (c) CyberAgent, Inc.
110 | - Image data: Unsplash License by https://unsplash.com
111 | - Video data: Pixabay License by https://pixabay.com/
112 |
--------------------------------------------------------------------------------
/databases/database.seed.sqlite:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/databases/database.seed.sqlite
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | kill_signal = "SIGINT"
2 | kill_timeout = 5
3 | processes = []
4 |
5 | [env]
6 |
7 | [experimental]
8 | auto_rollback = true
9 |
10 | [[services]]
11 | http_checks = []
12 | internal_port = 8080
13 | processes = ["app"]
14 | protocol = "tcp"
15 | script_checks = []
16 | [services.concurrency]
17 | hard_limit = 25
18 | soft_limit = 20
19 | type = "connections"
20 |
21 | [[services.ports]]
22 | force_https = true
23 | handlers = ["http"]
24 | port = 80
25 |
26 | [[services.ports]]
27 | handlers = ["tls", "http"]
28 | port = 443
29 |
30 | [[services.tcp_checks]]
31 | grace_period = "1s"
32 | interval = "15s"
33 | restart_limit = 0
34 | timeout = "2s"
35 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <%= title %>
8 |
9 | <% for (var href of videos) { %>
10 |
11 | <% } %>
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "license": "MPL-2.0",
4 | "scripts": {
5 | "start": "npm-run-all -p start:client start:server",
6 | "start:client": "cross-env NODE_ENV=development vite build --watch",
7 | "start:server": "nodemon --watch src/server --ext 'ts,tsx' --exec 'ts-node src/server'",
8 | "build": "npm-run-all -s build:tsc build:vite",
9 | "build:tsc": "tsc",
10 | "build:vite": "cross-env NODE_ENV=development vite build",
11 | "format": "npm-run-all -s format:*",
12 | "format:prettier": "prettier --write './**/*.{ts,tsx,js,jsx,json}'",
13 | "format:eslint": "eslint ./ --fix --ext .js,.jsx,.ts,.tsx",
14 | "format:stylelint": "stylelint --ignore-path .gitignore --fix './**/styles.{js,ts}' './**/*.styles.{js,ts}' './**/*.css'",
15 | "seed": "npm-run-all -s clear:database seed:database",
16 | "seed:database": "cross-env SEED_BASE_UNIXTIME=1677855600 ts-node tools/seed.ts",
17 | "clear:database": "rm -f ./databases/database.sqlite"
18 | },
19 | "dependencies": {
20 | "@apollo/client": "3.8.0-alpha.7",
21 | "@apollo/server": "4.3.0",
22 | "@as-integrations/koa": "0.2.1",
23 | "@emotion/css": "11.10.5",
24 | "@js-temporal/polyfill": "0.4.3",
25 | "@koa/router": "12.0.0",
26 | "@types/koa-session": "5.10.6",
27 | "bcrypt": "5.1.0",
28 | "canvaskit-wasm": "0.38.0",
29 | "classnames": "2.3.2",
30 | "core-js": "3.29.0",
31 | "currency-formatter": "1.5.9",
32 | "date-time-format-timezone": "1.0.22",
33 | "formik": "2.2.9",
34 | "graphql": "16.6.0",
35 | "http-graceful-shutdown": "3.1.13",
36 | "koa": "2.14.1",
37 | "koa-bodyparser": "4.3.0",
38 | "koa-logger": "3.2.1",
39 | "koa-route": "3.2.0",
40 | "koa-send": "5.0.1",
41 | "koa-session": "6.4.0",
42 | "koa-static": "5.0.0",
43 | "lodash": "4.17.21",
44 | "modern-css-reset": "1.4.0",
45 | "react": "18.2.0",
46 | "react-dom": "18.2.0",
47 | "react-error-boundary": "3.1.4",
48 | "react-helmet": "6.1.0",
49 | "react-icons": "4.7.1",
50 | "react-is": "18.2.0",
51 | "react-overlays": "5.2.1",
52 | "react-router-dom": "6.4.5",
53 | "recoil": "0.7.6",
54 | "reflect-metadata": "0.1.13",
55 | "setimmediate": "1.0.5",
56 | "sqlite3": "5.1.4",
57 | "styled-components": "5.3.6",
58 | "throttle-debounce": "5.0.0",
59 | "typeorm": "0.3.11",
60 | "zipcode-ja": "0.0.7",
61 | "zod": "3.20.6"
62 | },
63 | "devDependencies": {
64 | "@babel/core": "7.20.5",
65 | "@types/bcrypt": "5.0.0",
66 | "@types/currency-formatter": "1.5.1",
67 | "@types/koa": "2.13.5",
68 | "@types/koa-bodyparser": "4.3.10",
69 | "@types/koa-logger": "3.1.2",
70 | "@types/koa-route": "3.2.5",
71 | "@types/koa-send": "4.1.3",
72 | "@types/koa-static": "4.0.2",
73 | "@types/lodash": "4.14.191",
74 | "@types/node": "18.11.15",
75 | "@types/react": "18.0.24",
76 | "@types/react-dom": "18.0.8",
77 | "@types/react-helmet": "6.1.6",
78 | "@types/throttle-debounce": "5.0.0",
79 | "@typescript-eslint/eslint-plugin": "5.45.0",
80 | "@typescript-eslint/parser": "5.45.0",
81 | "@vitejs/plugin-react": "3.1.0",
82 | "cross-env": "7.0.3",
83 | "csstype": "3.1.1",
84 | "eslint": "8.29.0",
85 | "eslint-config-prettier": "8.5.0",
86 | "eslint-import-resolver-typescript": "3.5.3",
87 | "eslint-plugin-import": "2.26.0",
88 | "eslint-plugin-react": "7.31.11",
89 | "eslint-plugin-react-hooks": "4.6.0",
90 | "eslint-plugin-sort": "2.4.0",
91 | "nodemon": "2.0.20",
92 | "npm-run-all": "4.1.5",
93 | "postcss": "8.4.21",
94 | "postcss-styled-syntax": "0.3.1",
95 | "prettier": "2.8.0",
96 | "prettier-plugin-packagejson": "2.4.3",
97 | "prettier-plugin-sort-json": "1.0.0",
98 | "stylelint": "15.1.0",
99 | "stylelint-config-recommended": "10.0.1",
100 | "stylelint-order": "6.0.2",
101 | "ts-node": "10.9.1",
102 | "typescript": "4.9.3",
103 | "vite": "4.1.4",
104 | "vite-plugin-ejs": "1.6.4",
105 | "vite-plugin-top-level-await": "1.2.4",
106 | "vite-plugin-wasm": "3.2.1"
107 | },
108 | "packageManager": "pnpm@7.24.3",
109 | "engines": {
110 | "node": ">=18.0.0",
111 | "pnpm": ">=7.24.3"
112 | }
113 | }
114 |
--------------------------------------------------------------------------------
/public/fonts/NotoSerifJP-Bold.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/fonts/NotoSerifJP-Bold.otf
--------------------------------------------------------------------------------
/public/fonts/NotoSerifJP-Regular.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/fonts/NotoSerifJP-Regular.otf
--------------------------------------------------------------------------------
/public/images/avatars/001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/001.jpg
--------------------------------------------------------------------------------
/public/images/avatars/002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/002.jpg
--------------------------------------------------------------------------------
/public/images/avatars/003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/003.jpg
--------------------------------------------------------------------------------
/public/images/avatars/004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/004.jpg
--------------------------------------------------------------------------------
/public/images/avatars/005.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/005.jpg
--------------------------------------------------------------------------------
/public/images/avatars/006.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/006.jpg
--------------------------------------------------------------------------------
/public/images/avatars/007.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/007.jpg
--------------------------------------------------------------------------------
/public/images/avatars/008.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/008.jpg
--------------------------------------------------------------------------------
/public/images/avatars/009.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/009.jpg
--------------------------------------------------------------------------------
/public/images/avatars/010.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/010.jpg
--------------------------------------------------------------------------------
/public/images/avatars/011.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/011.jpg
--------------------------------------------------------------------------------
/public/images/avatars/012.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/012.jpg
--------------------------------------------------------------------------------
/public/images/avatars/013.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/013.jpg
--------------------------------------------------------------------------------
/public/images/avatars/014.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/014.jpg
--------------------------------------------------------------------------------
/public/images/avatars/015.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/015.jpg
--------------------------------------------------------------------------------
/public/images/avatars/016.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/016.jpg
--------------------------------------------------------------------------------
/public/images/avatars/017.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/017.jpg
--------------------------------------------------------------------------------
/public/images/avatars/018.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/018.jpg
--------------------------------------------------------------------------------
/public/images/avatars/019.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/019.jpg
--------------------------------------------------------------------------------
/public/images/avatars/020.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/avatars/020.jpg
--------------------------------------------------------------------------------
/public/images/products/apple/001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/apple/001.jpg
--------------------------------------------------------------------------------
/public/images/products/apple/002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/apple/002.jpg
--------------------------------------------------------------------------------
/public/images/products/apple/003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/apple/003.jpg
--------------------------------------------------------------------------------
/public/images/products/apple/004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/apple/004.jpg
--------------------------------------------------------------------------------
/public/images/products/apple/005.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/apple/005.jpg
--------------------------------------------------------------------------------
/public/images/products/apple/006.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/apple/006.jpg
--------------------------------------------------------------------------------
/public/images/products/apple/007.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/apple/007.jpg
--------------------------------------------------------------------------------
/public/images/products/banana/001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/banana/001.jpg
--------------------------------------------------------------------------------
/public/images/products/banana/002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/banana/002.jpg
--------------------------------------------------------------------------------
/public/images/products/banana/003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/banana/003.jpg
--------------------------------------------------------------------------------
/public/images/products/banana/004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/banana/004.jpg
--------------------------------------------------------------------------------
/public/images/products/banana/005.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/banana/005.jpg
--------------------------------------------------------------------------------
/public/images/products/banana/006.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/banana/006.jpg
--------------------------------------------------------------------------------
/public/images/products/banana/007.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/banana/007.jpg
--------------------------------------------------------------------------------
/public/images/products/banana/008.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/banana/008.jpg
--------------------------------------------------------------------------------
/public/images/products/blueberry/001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/blueberry/001.jpg
--------------------------------------------------------------------------------
/public/images/products/blueberry/002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/blueberry/002.jpg
--------------------------------------------------------------------------------
/public/images/products/blueberry/003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/blueberry/003.jpg
--------------------------------------------------------------------------------
/public/images/products/blueberry/004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/blueberry/004.jpg
--------------------------------------------------------------------------------
/public/images/products/blueberry/005.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/blueberry/005.jpg
--------------------------------------------------------------------------------
/public/images/products/blueberry/006.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/blueberry/006.jpg
--------------------------------------------------------------------------------
/public/images/products/blueberry/007.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/blueberry/007.jpg
--------------------------------------------------------------------------------
/public/images/products/blueberry/008.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/blueberry/008.jpg
--------------------------------------------------------------------------------
/public/images/products/blueberry/009.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/blueberry/009.jpg
--------------------------------------------------------------------------------
/public/images/products/cabbage/001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/cabbage/001.jpg
--------------------------------------------------------------------------------
/public/images/products/cabbage/002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/cabbage/002.jpg
--------------------------------------------------------------------------------
/public/images/products/cabbage/003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/cabbage/003.jpg
--------------------------------------------------------------------------------
/public/images/products/cabbage/004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/cabbage/004.jpg
--------------------------------------------------------------------------------
/public/images/products/cabbage/005.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/cabbage/005.jpg
--------------------------------------------------------------------------------
/public/images/products/cabbage/006.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/cabbage/006.jpg
--------------------------------------------------------------------------------
/public/images/products/cabbage/007.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/cabbage/007.jpg
--------------------------------------------------------------------------------
/public/images/products/carrot/001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/carrot/001.jpg
--------------------------------------------------------------------------------
/public/images/products/carrot/002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/carrot/002.jpg
--------------------------------------------------------------------------------
/public/images/products/carrot/003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/carrot/003.jpg
--------------------------------------------------------------------------------
/public/images/products/carrot/004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/carrot/004.jpg
--------------------------------------------------------------------------------
/public/images/products/carrot/005.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/carrot/005.jpg
--------------------------------------------------------------------------------
/public/images/products/carrot/006.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/carrot/006.jpg
--------------------------------------------------------------------------------
/public/images/products/strawberry/001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/strawberry/001.jpg
--------------------------------------------------------------------------------
/public/images/products/strawberry/002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/strawberry/002.jpg
--------------------------------------------------------------------------------
/public/images/products/strawberry/003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/strawberry/003.jpg
--------------------------------------------------------------------------------
/public/images/products/strawberry/004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/strawberry/004.jpg
--------------------------------------------------------------------------------
/public/images/products/strawberry/005.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/strawberry/005.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/001.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/001.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/002.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/002.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/003.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/003.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/004.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/004.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/005.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/005.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/006.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/006.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/007.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/007.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/008.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/008.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/009.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/009.jpg
--------------------------------------------------------------------------------
/public/images/products/tomato/010.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/images/products/tomato/010.jpg
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 |
--------------------------------------------------------------------------------
/public/videos/001.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/videos/001.mp4
--------------------------------------------------------------------------------
/public/videos/002.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/videos/002.mp4
--------------------------------------------------------------------------------
/public/videos/003.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CyberAgentHack/web-speed-hackathon-2023/16cc3f416e1cafcd46b60ad4e3540c16147cc5cc/public/videos/003.mp4
--------------------------------------------------------------------------------
/src/client/components/application/App/App.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 |
3 | import { SignInModal } from '../../modal/SignInModal';
4 | import { SignUpModal } from '../../modal/SignUpModal';
5 | import { Providers } from '../Providers';
6 | import { Routes } from '../Routes';
7 |
8 | export const App: FC = () => (
9 |
10 |
11 |
12 |
13 |
14 | );
15 |
--------------------------------------------------------------------------------
/src/client/components/application/App/index.ts:
--------------------------------------------------------------------------------
1 | export { App } from './App';
2 |
--------------------------------------------------------------------------------
/src/client/components/application/Layout/Layout.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | max-width: 100vw;
5 | `;
6 |
--------------------------------------------------------------------------------
/src/client/components/application/Layout/Layout.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, ReactNode } from 'react';
2 |
3 | import { Footer } from '../../navigators/Footer/Footer';
4 | import { Header } from '../../navigators/Header/Header';
5 |
6 | import * as styles from './Layout.styles';
7 |
8 | type Props = {
9 | children: ReactNode;
10 | };
11 |
12 | export const Layout: FC = ({ children }) => (
13 | <>
14 |
15 | {children}
16 |
17 | >
18 | );
19 |
--------------------------------------------------------------------------------
/src/client/components/application/Layout/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Layout';
2 |
--------------------------------------------------------------------------------
/src/client/components/application/Providers/Providers.tsx:
--------------------------------------------------------------------------------
1 | import { ApolloProvider, SuspenseCache } from '@apollo/client';
2 | import type { FC, ReactNode } from 'react';
3 | import { Suspense } from 'react';
4 | import { ErrorBoundary } from 'react-error-boundary';
5 | import { BrowserRouter } from 'react-router-dom';
6 | import { RecoilRoot } from 'recoil';
7 |
8 | import { Fallback } from '../../../pages/Fallback';
9 | import { apolloClient } from '../../../utils//apollo_client';
10 |
11 | type Props = {
12 | children: ReactNode;
13 | };
14 |
15 | const suspenseCache = new SuspenseCache();
16 |
17 | export const Providers: FC = ({ children }) => (
18 |
19 |
20 |
21 |
22 | {children}
23 |
24 |
25 |
26 |
27 | );
28 |
--------------------------------------------------------------------------------
/src/client/components/application/Providers/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Providers';
2 |
--------------------------------------------------------------------------------
/src/client/components/application/Routes/Routes.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import * as Router from 'react-router-dom';
3 |
4 | import { NotFound } from '../../../pages/NotFound';
5 | import { Order } from '../../../pages/Order';
6 | import { OrderComplete } from '../../../pages/OrderComplete';
7 | import { ProductDetail } from '../../../pages/ProductDetail';
8 | import { Top } from '../../../pages/Top';
9 |
10 | import { useScrollToTop } from './hooks';
11 |
12 | export const Routes: FC = () => {
13 | useScrollToTop();
14 |
15 | return (
16 |
17 | } path="/" />
18 | } path="/product/:productId" />
19 | } path="/order" />
20 | } path="/order/complete" />
21 | } path="*" />
22 |
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/client/components/application/Routes/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useScrollToTop';
2 |
--------------------------------------------------------------------------------
/src/client/components/application/Routes/hooks/useScrollToTop.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useLocation } from 'react-router-dom';
3 |
4 | export const useScrollToTop = () => {
5 | const { pathname } = useLocation();
6 |
7 | useEffect(() => {
8 | window.scrollTo(0, 0);
9 | }, [pathname]);
10 | };
11 |
--------------------------------------------------------------------------------
/src/client/components/application/Routes/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Routes';
2 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductCard/ProductCard.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const inner = () => css`
4 | display: inline-grid;
5 | overflow: hidden;
6 | position: relative;
7 | width: 224px;
8 | `;
9 |
10 | export const label = () => css`
11 | left: 0;
12 | margin: 4px;
13 | position: absolute;
14 | top: 0;
15 | `;
16 |
17 | export const image = () => css`
18 | border-radius: 8px;
19 | display: grid;
20 | overflow: hidden;
21 | `;
22 |
23 | export const description = () => css`
24 | display: grid;
25 | padding: 4px;
26 | `;
27 |
28 | export const itemName = () => css`
29 | -webkit-box-orient: vertical;
30 | color: #222222;
31 | display: -webkit-box;
32 | height: 3rem;
33 | -webkit-line-clamp: 2;
34 | line-height: 1.5rem;
35 | overflow: hidden;
36 | text-overflow: ellipsis;
37 | `;
38 |
39 | export const itemPrice = () => css`
40 | color: #222222;
41 | justify-self: right;
42 | `;
43 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductCard/ProductCard.tsx:
--------------------------------------------------------------------------------
1 | import * as currencyFormatter from 'currency-formatter';
2 | import type { FC } from 'react';
3 |
4 | import type { ProductFragmentResponse } from '../../../graphql/fragments';
5 | import { useActiveOffer } from '../../../hooks/useActiveOffer';
6 | import { Anchor } from '../../foundation/Anchor';
7 | import { AspectRatio } from '../../foundation/AspectRatio';
8 | import { Image } from '../../foundation/Image';
9 | import { ProductOfferLabel } from '../../product/ProductOfferLabel';
10 |
11 | import * as styles from './ProductCard.styles';
12 |
13 | type Props = {
14 | product: ProductFragmentResponse;
15 | };
16 |
17 | export const ProductCard: FC = ({ product }) => {
18 | const thumbnailFile = product.media.find((productMedia) => productMedia.isThumbnail)?.file;
19 |
20 | const { activeOffer } = useActiveOffer(product);
21 | const price = activeOffer?.price ?? product.price;
22 |
23 | return (
24 |
25 |
26 | {thumbnailFile ? (
27 |
32 | ) : null}
33 |
34 |
{product.name}
35 |
{currencyFormatter.format(price, { code: 'JPY', precision: 0 })}
36 |
37 | {activeOffer !== undefined && (
38 |
41 | )}
42 |
43 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductCard/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductCard';
2 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductGridList/ProductGridList.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const cardList = () => css`
4 | align-items: center;
5 | display: flex;
6 | overflow-x: scroll;
7 | padding: 8px 0;
8 | width: 100%;
9 | `;
10 |
11 | export const cardListItem = () => css`
12 | display: inline-block;
13 | margin: 0 8px;
14 | `;
15 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductGridList/ProductGridList.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 |
3 | import type { FeatureSectionFragmentResponse } from '../../../graphql/fragments';
4 | import { ProductCard } from '../ProductCard';
5 |
6 | import * as styles from './ProductGridList.styles';
7 |
8 | type Props = {
9 | featureSection: FeatureSectionFragmentResponse;
10 | };
11 |
12 | export const ProductGridList: FC = ({ featureSection }) => {
13 | const products = featureSection.items.map((item) => item.product);
14 |
15 | return (
16 |
17 | {products.map((product) => {
18 | return (
19 | -
20 |
21 |
22 | );
23 | })}
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductGridList/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductGridList';
2 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductList/ProductList.tsx:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import type { FC } from 'react';
3 | import { memo } from 'react';
4 |
5 | import type { FeatureSectionFragmentResponse } from '../../../graphql/fragments';
6 | import { DeviceType, GetDeviceType } from '../../foundation/GetDeviceType';
7 | import { ProductGridList } from '../ProductGridList';
8 | import { ProductListSlider } from '../ProductListSlider';
9 |
10 | type Props = {
11 | featureSection: FeatureSectionFragmentResponse;
12 | };
13 |
14 | export const ProductList: FC = memo(({ featureSection }) => {
15 | return (
16 |
17 | {({ deviceType }) => {
18 | switch (deviceType) {
19 | case DeviceType.DESKTOP: {
20 | return ;
21 | }
22 | case DeviceType.MOBILE: {
23 | return ;
24 | }
25 | }
26 | }}
27 |
28 | );
29 | }, _.isEqual);
30 |
31 | ProductList.displayName = 'ProductList';
32 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductList/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductList';
2 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductListSlideButton/ProductListSlideButton.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | background: #e5e5e5;
5 | border-radius: 100%;
6 | cursor: pointer;
7 | display: grid;
8 | height: 36px;
9 | opacity: 1;
10 | place-items: center;
11 | width: 36px;
12 | `;
13 |
14 | export const container__disabled = () => css`
15 | cursor: default;
16 | opacity: 0.5;
17 | `;
18 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductListSlideButton/ProductListSlideButton.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { FC } from 'react';
3 |
4 | import { Icon } from '../../foundation/Icon';
5 |
6 | import * as styles from './ProductListSlideButton.styles';
7 |
8 | export const ArrowType = {
9 | LEFT: 'LEFT',
10 | RIGHT: 'RIGHT',
11 | } as const;
12 | export type ArrowType = typeof ArrowType[keyof typeof ArrowType];
13 |
14 | type Props = {
15 | arrowType: ArrowType;
16 | disabled: boolean;
17 | onClick: () => void;
18 | };
19 |
20 | export const ProductListSlideButton: FC = ({ arrowType, disabled, onClick }) => {
21 | return (
22 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductListSlideButton/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductListSlideButton';
2 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductListSlider/ProductListSlider.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | align-items: center;
5 | display: flex;
6 | gap: 8px;
7 | padding: 0 16px;
8 | `;
9 |
10 | export const slideButton = () => css`
11 | flex-grow: 0;
12 | flex-shrink: 0;
13 | `;
14 |
15 | export const listWrapper = () => css`
16 | overflow: hidden;
17 | width: 100%;
18 | `;
19 |
20 | export const list = ({ slideIndex, visibleItemCount }: { slideIndex: number; visibleItemCount: number }) => css`
21 | align-items: center;
22 | display: grid;
23 | grid-auto-columns: calc(100% / ${visibleItemCount});
24 | grid-auto-flow: column;
25 | justify-content: flex-start;
26 | transform: translateX(calc(${slideIndex} / ${visibleItemCount} * -100%));
27 | transition-duration: 0.5s;
28 | transition-property: transform;
29 | transition-timing-function: ease-out;
30 | width: 100%;
31 | `;
32 |
33 | export const item = () => css`
34 | align-items: flex-start;
35 | display: inline-flex;
36 | justify-content: center;
37 | margin: 0px 8px;
38 | `;
39 |
40 | export const item__hidden = () => css`
41 | opacity: 0.5;
42 | pointer-events: none;
43 | `;
44 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductListSlider/ProductListSlider.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { FC } from 'react';
3 |
4 | import type { FeatureSectionFragmentResponse } from '../../../graphql/fragments';
5 | import { ProductCard } from '../ProductCard';
6 | import { ArrowType, ProductListSlideButton } from '../ProductListSlideButton';
7 |
8 | import * as styles from './ProductListSlider.styles';
9 | import { useSlider } from './hooks/useSlider';
10 |
11 | type Props = {
12 | featureSection: FeatureSectionFragmentResponse;
13 | };
14 |
15 | export const ProductListSlider: FC = ({ featureSection }) => {
16 | const products = featureSection.items.map((item) => item.product);
17 |
18 | const { containerElementRef, setSlideIndex, slideIndex, visibleItemCount } = useSlider({
19 | items: products,
20 | });
21 |
22 | return (
23 |
24 |
25 |
setSlideIndex(slideIndex - visibleItemCount)}
29 | />
30 |
31 |
32 |
33 | {products.map((product, index) => {
34 | const hidden = index < slideIndex || slideIndex + visibleItemCount <= index;
35 | return (
36 | -
42 |
43 |
44 | );
45 | })}
46 |
47 |
48 |
49 |
= products.length}
52 | onClick={() => setSlideIndex(slideIndex + visibleItemCount)}
53 | />
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductListSlider/hooks/useSlider.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from 'react';
2 | import { throttle } from 'throttle-debounce';
3 |
4 | const ITEM_MIN_WIDTH = 250 as const;
5 |
6 | export const useSlider = ({ items }: { items: unknown[] }) => {
7 | const containerElementRef = useRef(null);
8 | const [visibleItemCount, setVisibleItemCount] = useState(1);
9 | const [_slideIndex, setSlideIndex] = useState(0);
10 | const slideIndex = Math.min(Math.max(0, _slideIndex), items.length - 1);
11 |
12 | useEffect(() => {
13 | const updateVisibleItemCount = throttle(500, () => {
14 | setVisibleItemCount(() => {
15 | const containerWidth = containerElementRef.current?.getBoundingClientRect().width ?? 0;
16 | return Math.max(Math.floor(containerWidth / ITEM_MIN_WIDTH), 1);
17 | });
18 | });
19 |
20 | let timer = (function tick() {
21 | return setImmediate(() => {
22 | updateVisibleItemCount();
23 | timer = tick();
24 | });
25 | })();
26 |
27 | return () => {
28 | clearImmediate(timer);
29 | };
30 | }, []);
31 |
32 | return {
33 | containerElementRef,
34 | setSlideIndex,
35 | slideIndex,
36 | visibleItemCount,
37 | };
38 | };
39 |
--------------------------------------------------------------------------------
/src/client/components/feature/ProductListSlider/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductListSlider';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Anchor/Anchor.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | text-decoration: none;
5 | `;
6 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Anchor/Anchor.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps, FC } from 'react';
2 |
3 | import * as styles from './Anchor.styles';
4 |
5 | type Props = Omit, 'className'>;
6 |
7 | export const Anchor: FC = ({ children, href, ...rest }) => (
8 |
9 | {children}
10 |
11 | );
12 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Anchor/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Anchor';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/AspectRatio/AspectRatio.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = ({ clientHeight }: { clientHeight: number | undefined }) => css`
4 | height: ${clientHeight ?? 0}px;
5 | position: relative;
6 | width: 100%;
7 | `;
8 |
--------------------------------------------------------------------------------
/src/client/components/foundation/AspectRatio/AspectRatio.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, ReactNode } from 'react';
2 | import { useEffect, useRef, useState } from 'react';
3 | import { throttle } from 'throttle-debounce';
4 |
5 | import * as styles from './AspectRatio.styles';
6 |
7 | type Props = {
8 | ratioWidth: number;
9 | ratioHeight: number;
10 | children: ReactNode;
11 | };
12 |
13 | export const AspectRatio: FC = ({ children, ratioHeight, ratioWidth }) => {
14 | const containerRef = useRef(null);
15 | const [clientHeight, setClientHeight] = useState(0);
16 |
17 | useEffect(() => {
18 | const updateClientHeight = throttle(1000, () => {
19 | const width = containerRef.current?.getBoundingClientRect().width ?? 0;
20 | const height = (width * ratioHeight) / ratioWidth;
21 | setClientHeight(height);
22 | });
23 |
24 | let timer = (function tick() {
25 | return setImmediate(() => {
26 | updateClientHeight();
27 | timer = tick();
28 | });
29 | })();
30 |
31 | return () => {
32 | clearImmediate(timer);
33 | };
34 | }, [ratioHeight, ratioWidth]);
35 |
36 | return (
37 |
38 | {children}
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/client/components/foundation/AspectRatio/index.ts:
--------------------------------------------------------------------------------
1 | export * from './AspectRatio';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/GetDeviceType/GetDeviceType.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from 'react';
2 | import { Component } from 'react';
3 |
4 | export const DeviceType = {
5 | DESKTOP: 'DESKTOP',
6 | MOBILE: 'MOBILE',
7 | } as const;
8 | export type DeviceType = typeof DeviceType[keyof typeof DeviceType];
9 |
10 | type Props = {
11 | children: ({ deviceType }: { deviceType: DeviceType }) => ReactNode;
12 | };
13 |
14 | export class GetDeviceType extends Component {
15 | private _timer: number | null;
16 | private _windowWidth: number;
17 |
18 | constructor(props: Props) {
19 | super(props);
20 | this._windowWidth = window.innerWidth;
21 | this._timer = null;
22 | }
23 |
24 | componentDidMount(): void {
25 | this._checkIsDesktop();
26 | }
27 |
28 | componentWillUnmount(): void {
29 | if (this._timer != null) {
30 | window.clearImmediate(this._timer);
31 | }
32 | }
33 |
34 | private _checkIsDesktop() {
35 | this._windowWidth = window.innerWidth;
36 | this.forceUpdate(() => {
37 | this._timer = window.setImmediate(this._checkIsDesktop.bind(this));
38 | });
39 | }
40 |
41 | render() {
42 | const { children: render } = this.props;
43 | return render({
44 | deviceType: this._windowWidth >= 1024 ? DeviceType.DESKTOP : DeviceType.MOBILE,
45 | });
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/client/components/foundation/GetDeviceType/index.ts:
--------------------------------------------------------------------------------
1 | export * from './GetDeviceType';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Icon/Icon.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = ({ color, height, width }: { width: number; height: number; color: string }) => css`
4 | color: ${color};
5 | height: ${height}px;
6 | width: ${width}px;
7 | `;
8 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Icon/Icon.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { FC } from 'react';
3 | import * as Icons from 'react-icons/fa';
4 |
5 | import * as styles from './Icon.styles';
6 |
7 | type Props = {
8 | type: keyof typeof Icons;
9 | width: number;
10 | height: number;
11 | color: string;
12 | };
13 |
14 | export const Icon: FC = ({ color, height, type, width }) => {
15 | const Icon = Icons[type];
16 | return (
17 |
18 |
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Icon/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Icon';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Image/Image.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | object-fit: cover;
5 | `;
6 |
7 | export const container__fill = () => css`
8 | height: 100%;
9 | inset: 0;
10 | position: absolute;
11 | width: 100%;
12 | `;
13 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Image/Image.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { ComponentProps, FC } from 'react';
3 |
4 | import * as styles from './Image.styles';
5 |
6 | type Props = Omit, 'className'> & {
7 | fill?: boolean;
8 | };
9 |
10 | export const Image: FC = ({ fill, ...rest }) => {
11 | return (
12 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Image/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Image';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Modal/Modal.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | left: 50%;
5 | position: fixed;
6 | top: 50%;
7 | transform: translate(-50%, -50%);
8 | z-index: 100;
9 | `;
10 |
11 | export const backdrop = () => css`
12 | background-color: black;
13 | inset: 0;
14 | opacity: 0.5;
15 | position: fixed;
16 | z-index: 100;
17 | `;
18 |
19 | export const inner = () => css`
20 | background-color: #ffffff;
21 | border-radius: 8px;
22 | max-width: 500px;
23 | width: calc(100vw - 24px);
24 | `;
25 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Modal/Modal.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, ReactNode } from 'react';
2 | import OverlaysModal, { type RenderModalBackdropProps } from 'react-overlays/Modal';
3 |
4 | import * as styles from './Modal.styles';
5 |
6 | const Backdrop: FC = (props) => ;
7 |
8 | type Props = {
9 | show: boolean;
10 | onHide: () => void;
11 | children: ReactNode;
12 | };
13 |
14 | export const Modal: FC = ({ children, onHide, show }) => (
15 |
16 |
17 | {children}
18 |
19 |
20 | );
21 |
--------------------------------------------------------------------------------
/src/client/components/foundation/Modal/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Modal';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/OutlineButton/OutlineButton.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | background-color: #ffffff;
5 | border: 1px solid rgba(0, 0, 0, 0.25);
6 | border-radius: 8px;
7 | color: #222222;
8 | `;
9 |
10 | export const container__base = () => css`
11 | font-size: 0.75rem;
12 | padding: 4px 16px;
13 | `;
14 |
15 | export const container__lg = () => css`
16 | font-size: 1rem;
17 | padding: 8px 24px;
18 | `;
19 |
--------------------------------------------------------------------------------
/src/client/components/foundation/OutlineButton/OutlineButton.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import type { ComponentProps, FC } from 'react';
3 |
4 | import * as styles from './OutlineButton.styles';
5 |
6 | type Size = 'base' | 'lg';
7 | type Props = Omit, 'className'> & {
8 | size: Size;
9 | };
10 |
11 | export const OutlineButton: FC = ({ children, size, ...rest }) => {
12 | return (
13 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/src/client/components/foundation/OutlineButton/index.ts:
--------------------------------------------------------------------------------
1 | export * from './OutlineButton';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/PrimaryAnchor/PrimaryAnchor.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const inner = () => css`
4 | align-items: center;
5 | background-color: #3ba175;
6 | border-radius: 8px;
7 | color: #ffffff;
8 | display: inline-grid;
9 | justify-content: center;
10 | padding: 8px 24px;
11 | `;
12 |
13 | export const container__base = () => css`
14 | height: auto;
15 | width: auto;
16 | `;
17 |
18 | export const container__lg = () => css`
19 | height: 60px;
20 | width: 200px;
21 | `;
22 |
--------------------------------------------------------------------------------
/src/client/components/foundation/PrimaryAnchor/PrimaryAnchor.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { FC } from 'react';
3 |
4 | import { Anchor } from '../Anchor';
5 |
6 | import * as styles from './PrimaryAnchor.styles';
7 |
8 | type Size = 'base' | 'lg';
9 | type Props = {
10 | size: Size;
11 | href: string;
12 | children: string;
13 | };
14 |
15 | export const PrimaryAnchor: FC = ({ children, href, size }) => (
16 |
17 |
23 | {children}
24 |
25 |
26 | );
27 |
--------------------------------------------------------------------------------
/src/client/components/foundation/PrimaryAnchor/index.ts:
--------------------------------------------------------------------------------
1 | export * from './PrimaryAnchor';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/PrimaryButton/PrimaryButton.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | background-color: #3ba175;
5 | border-radius: 8px;
6 | color: #ffffff;
7 | padding: 8px 24px;
8 | `;
9 |
10 | export const container__sm = () => css`
11 | height: auto;
12 | width: auto;
13 | `;
14 |
15 | export const container__base = () => css`
16 | height: 40px;
17 | width: 140px;
18 | `;
19 |
20 | export const container__lg = () => css`
21 | height: 60px;
22 | width: 200px;
23 | `;
24 |
--------------------------------------------------------------------------------
/src/client/components/foundation/PrimaryButton/PrimaryButton.tsx:
--------------------------------------------------------------------------------
1 | import classnames from 'classnames';
2 | import type { ComponentProps, FC } from 'react';
3 |
4 | import * as styles from './PrimaryButton.styles';
5 |
6 | type Size = 'sm' | 'base' | 'lg';
7 | type Props = Omit, 'className'> & {
8 | size: Size;
9 | };
10 |
11 | export const PrimaryButton: FC = ({ children, size, ...rest }) => {
12 | return (
13 |
22 | );
23 | };
24 |
--------------------------------------------------------------------------------
/src/client/components/foundation/PrimaryButton/index.ts:
--------------------------------------------------------------------------------
1 | export * from './PrimaryButton';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/TextArea/TextArea.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | color: #222222;
5 | display: flex;
6 | flex-direction: column;
7 | font-weight: 700;
8 | gap: 8px;
9 | `;
10 |
11 | export const textarea = () => css`
12 | border: 1px solid rgba(0, 0, 0, 0.25);
13 | border-radius: 4px;
14 | color: #222222;
15 | font-size: 1rem;
16 | font-weight: 400;
17 | padding: 8px;
18 |
19 | &::placeholder {
20 | color: #999999;
21 | }
22 | `;
23 |
--------------------------------------------------------------------------------
/src/client/components/foundation/TextArea/TextArea.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps, FC } from 'react';
2 |
3 | import * as styles from './TextArea.styles';
4 |
5 | type Props = Omit, 'className'> & {
6 | label: string;
7 | };
8 |
9 | export const TextArea: FC = ({ label, ...rest }) => (
10 |
14 | );
15 |
--------------------------------------------------------------------------------
/src/client/components/foundation/TextArea/index.ts:
--------------------------------------------------------------------------------
1 | export * from './TextArea';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/TextInput/TextInput.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | color: #222222;
5 | display: flex;
6 | flex-direction: column;
7 | font-weight: 700;
8 | gap: 8px;
9 | width: 100%;
10 | `;
11 |
12 | export const input = () => css`
13 | border: 1px solid rgba(0, 0, 0, 0.25);
14 | border-radius: 4px;
15 | color: #222222;
16 | font-size: 1rem;
17 | font-weight: 400;
18 | padding: 8px;
19 |
20 | &::placeholder {
21 | color: #999999;
22 | }
23 | `;
24 |
--------------------------------------------------------------------------------
/src/client/components/foundation/TextInput/TextInput.tsx:
--------------------------------------------------------------------------------
1 | import type { ComponentProps, FC } from 'react';
2 |
3 | import * as styles from './TextInput.styles';
4 |
5 | type Props = Omit, 'className'> & {
6 | label: string;
7 | };
8 |
9 | export const TextInput: FC = ({ label, ...rest }) => (
10 |
14 | );
15 |
--------------------------------------------------------------------------------
/src/client/components/foundation/TextInput/index.ts:
--------------------------------------------------------------------------------
1 | export { TextInput } from './TextInput';
2 |
--------------------------------------------------------------------------------
/src/client/components/foundation/WidthRestriction/WidthRestriction.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | width: 100%;
5 | `;
6 |
7 | export const inner = ({ width }: { width: number | undefined }) => css`
8 | margin: 0 auto;
9 | width: ${width}px;
10 | `;
11 |
--------------------------------------------------------------------------------
/src/client/components/foundation/WidthRestriction/WidthRestriction.tsx:
--------------------------------------------------------------------------------
1 | import type { FC, ReactNode } from 'react';
2 | import { useEffect, useRef, useState } from 'react';
3 | import { throttle } from 'throttle-debounce';
4 |
5 | import * as styles from './WidthRestriction.styles';
6 |
7 | type Props = {
8 | children: ReactNode;
9 | };
10 |
11 | export const WidthRestriction: FC = ({ children }) => {
12 | const containerRef = useRef(null);
13 | const [clientWidth, setClientWidth] = useState(0);
14 |
15 | const isReady = clientWidth !== 0;
16 |
17 | useEffect(() => {
18 | const updateClientWidth = throttle(1000, () => {
19 | const width = containerRef.current?.getBoundingClientRect().width ?? 0;
20 | // 横幅を最大 1024px にする
21 | setClientWidth(Math.min(width, 1024));
22 | });
23 |
24 | let timer = (function tick() {
25 | return setImmediate(() => {
26 | updateClientWidth();
27 | timer = tick();
28 | });
29 | })();
30 |
31 | return () => {
32 | clearImmediate(timer);
33 | };
34 | }, []);
35 |
36 | return (
37 |
38 |
{isReady ? children : null}
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/client/components/foundation/WidthRestriction/index.ts:
--------------------------------------------------------------------------------
1 | export * from './WidthRestriction';
2 |
--------------------------------------------------------------------------------
/src/client/components/modal/SignInModal/SignInModal.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const inner = () => css`
4 | display: flex;
5 | flex-direction: column;
6 | gap: 24px;
7 | justify-content: conter;
8 | padding: 24px;
9 | `;
10 |
11 | export const header = () => css`
12 | display: flex;
13 | justify-content: space-between;
14 | `;
15 |
16 | export const heading = () => css`
17 | font-size: 24px;
18 | `;
19 |
20 | export const switchToSignUpButton = () => css`
21 | color: #3ba175;
22 | `;
23 |
24 | export const form = () => css`
25 | display: grid;
26 | gap: 24px;
27 | `;
28 |
29 | export const inputList = () => css`
30 | display: grid;
31 | gap: 16px;
32 | `;
33 |
34 | export const submitButton = () => css`
35 | justify-self: center;
36 | `;
37 |
38 | export const error = () => css`
39 | color: #b00020;
40 | font-size: 0.875rem;
41 | line-height: 1;
42 | min-height: 1em;
43 | `;
44 |
--------------------------------------------------------------------------------
/src/client/components/modal/SignInModal/SignInModal.tsx:
--------------------------------------------------------------------------------
1 | import type { FormikErrors } from 'formik';
2 | import { useFormik } from 'formik';
3 | import type { FC } from 'react';
4 | import { useState } from 'react';
5 | import * as z from 'zod';
6 |
7 | import { useSignIn } from '../../../hooks/useSignIn';
8 | import { useCloseModal, useIsOpenModal, useOpenModal } from '../../../store/modal';
9 | import { Modal } from '../../foundation/Modal';
10 | import { PrimaryButton } from '../../foundation/PrimaryButton';
11 | import { TextInput } from '../../foundation/TextInput';
12 |
13 | import * as styles from './SignInModal.styles';
14 |
15 | const NOT_INCLUDED_AT_CHAR_REGEX = /^(?:[^@]*){6,}$/;
16 | const NOT_INCLUDED_SYMBOL_CHARS_REGEX = /^(?:(?:[a-zA-Z0-9]*){2,})+$/;
17 |
18 | // NOTE: 文字列に @ が含まれているか確認する
19 | const emailSchema = z.string().refine((v) => !NOT_INCLUDED_AT_CHAR_REGEX.test(v));
20 | // NOTE: 文字列に英数字以外の文字が含まれているか確認する
21 | const passwordSchema = z.string().refine((v) => !NOT_INCLUDED_SYMBOL_CHARS_REGEX.test(v));
22 |
23 | export type SignInForm = {
24 | email: string;
25 | password: string;
26 | };
27 |
28 | export const SignInModal: FC = () => {
29 | const isOpened = useIsOpenModal('SIGN_IN');
30 | const { signIn } = useSignIn();
31 |
32 | const handleOpenModal = useOpenModal();
33 | const handleCloseModal = useCloseModal();
34 |
35 | const [submitError, setSubmitError] = useState(null);
36 | const formik = useFormik({
37 | initialValues: {
38 | email: '',
39 | password: '',
40 | },
41 | async onSubmit(values, { resetForm }) {
42 | try {
43 | await signIn({
44 | variables: {
45 | email: values.email,
46 | password: values.password,
47 | },
48 | });
49 | resetForm();
50 | setSubmitError(null);
51 | handleCloseModal();
52 | } catch (err) {
53 | setSubmitError(err as Error);
54 | }
55 | },
56 | validate(values) {
57 | const errors: FormikErrors = {};
58 | if (values.email != '' && !emailSchema.safeParse(values.email).success) {
59 | errors['email'] = 'メールアドレスの形式が間違っています';
60 | }
61 | if (values.password != '' && !passwordSchema.safeParse(values.password).success) {
62 | errors['password'] = '英数字以外の文字を含めてください';
63 | }
64 | return errors;
65 | },
66 | validateOnChange: true,
67 | });
68 |
69 | return (
70 |
71 |
72 |
73 | ログイン
74 |
81 |
82 |
113 |
114 |
115 | );
116 | };
117 |
--------------------------------------------------------------------------------
/src/client/components/modal/SignInModal/index.ts:
--------------------------------------------------------------------------------
1 | export * from './SignInModal';
2 |
--------------------------------------------------------------------------------
/src/client/components/modal/SignUpModal/SignUpModal.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const inner = () => css`
4 | display: flex;
5 | flex-direction: column;
6 | gap: 24px;
7 | justify-content: center;
8 | padding: 24px;
9 | `;
10 |
11 | export const header = () => css`
12 | display: flex;
13 | justify-content: space-between;
14 | `;
15 |
16 | export const heading = () => css`
17 | font-size: 24px;
18 | `;
19 |
20 | export const switchToSignInButton = () => css`
21 | color: #3ba175;
22 | `;
23 |
24 | export const form = () => css`
25 | display: grid;
26 | gap: 24px;
27 | `;
28 |
29 | export const inputList = () => css`
30 | display: grid;
31 | gap: 16px;
32 | `;
33 |
34 | export const submitButton = () => css`
35 | justify-self: center;
36 | `;
37 |
38 | export const error = () => css`
39 | color: #b00020;
40 | font-size: 0.875rem;
41 | line-height: 1;
42 | min-height: 1em;
43 | `;
44 |
--------------------------------------------------------------------------------
/src/client/components/modal/SignUpModal/SignUpModal.tsx:
--------------------------------------------------------------------------------
1 | import type { FormikErrors } from 'formik';
2 | import { useFormik } from 'formik';
3 | import type { FC } from 'react';
4 | import { useState } from 'react';
5 | import * as z from 'zod';
6 |
7 | import { useSignUp } from '../../../hooks/useSignUp';
8 | import { useCloseModal, useIsOpenModal, useOpenModal } from '../../../store/modal';
9 | import { Modal } from '../../foundation/Modal';
10 | import { PrimaryButton } from '../../foundation/PrimaryButton';
11 | import { TextInput } from '../../foundation/TextInput';
12 |
13 | import * as styles from './SignUpModal.styles';
14 |
15 | const NOT_INCLUDED_AT_CHAR_REGEX = /^(?:[^@]*){6,}$/;
16 | const NOT_INCLUDED_SYMBOL_CHARS_REGEX = /^(?:(?:[a-zA-Z0-9]*){2,})+$/;
17 |
18 | // NOTE: 文字列に @ が含まれているか確認する
19 | const emailSchema = z.string().refine((v) => !NOT_INCLUDED_AT_CHAR_REGEX.test(v));
20 | // NOTE: 文字列に英数字以外の文字が含まれているか確認する
21 | const passwordSchema = z.string().refine((v) => !NOT_INCLUDED_SYMBOL_CHARS_REGEX.test(v));
22 |
23 | export type SignUpForm = {
24 | email: string;
25 | name: string;
26 | password: string;
27 | };
28 |
29 | export const SignUpModal: FC = () => {
30 | const isOpened = useIsOpenModal('SIGN_UP');
31 | const { signUp } = useSignUp();
32 |
33 | const handleOpenModal = useOpenModal();
34 | const handleCloseModal = useCloseModal();
35 |
36 | const [submitError, setSubmitError] = useState(null);
37 | const formik = useFormik({
38 | initialValues: {
39 | email: '',
40 | name: '',
41 | password: '',
42 | },
43 | async onSubmit(values, { resetForm }) {
44 | try {
45 | await signUp({
46 | variables: {
47 | email: values.email,
48 | name: values.name,
49 | password: values.password,
50 | },
51 | });
52 | resetForm();
53 | setSubmitError(null);
54 | handleCloseModal();
55 | } catch (err) {
56 | setSubmitError(err as Error);
57 | }
58 | },
59 | validate(values) {
60 | const errors: FormikErrors = {};
61 | if (values.email != '' && !emailSchema.safeParse(values.email).success) {
62 | errors['email'] = 'メールアドレスの形式が間違っています';
63 | }
64 | if (values.password != '' && !passwordSchema.safeParse(values.password).success) {
65 | errors['password'] = '英数字以外の文字を含めてください';
66 | }
67 | return errors;
68 | },
69 | validateOnChange: true,
70 | });
71 |
72 | return (
73 |
74 |
75 |
76 | 会員登録
77 |
84 |
85 |
127 |
128 |
129 | );
130 | };
131 |
--------------------------------------------------------------------------------
/src/client/components/modal/SignUpModal/index.ts:
--------------------------------------------------------------------------------
1 | export * from './SignUpModal';
2 |
--------------------------------------------------------------------------------
/src/client/components/navigators/Footer/Footer.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | background-color: #f4f4f4;
5 | display: grid;
6 | gap: 24px;
7 | margin-top: 40px;
8 | padding: 32px 24px;
9 | `;
10 |
11 | export const itemList = () => css`
12 | display: flex;
13 | flex-direction: column;
14 | gap: 16px;
15 | `;
16 |
17 | export const itemList__desktop = () => css`
18 | flex-direction: row;
19 | `;
20 |
21 | export const itemList__mobile = () => css`
22 | flex-direction: column;
23 | `;
24 |
25 | export const item = () => css`
26 | color: #222222;
27 | font-size: 14px;
28 | `;
29 |
--------------------------------------------------------------------------------
/src/client/components/navigators/Footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { FC } from 'react';
3 | import { NavLink } from 'react-router-dom';
4 |
5 | import { DeviceType, GetDeviceType } from '../../foundation/GetDeviceType';
6 | import { Image } from '../../foundation/Image';
7 |
8 | import * as styles from './Footer.styles';
9 |
10 | const FOOTER_LINK_ITEMS = ['利用規約', 'お問い合わせ', 'Q&A', '運営会社', 'オーガニックとは'] as const;
11 |
12 | export const Footer: FC = () => {
13 | return (
14 |
15 | {({ deviceType }) => {
16 | return (
17 |
34 | );
35 | }}
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/src/client/components/navigators/Footer/index.ts:
--------------------------------------------------------------------------------
1 | export { Footer } from './Footer';
2 |
--------------------------------------------------------------------------------
/src/client/components/navigators/Header/Header.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | align-items: center;
5 | border-bottom: 1px solid rgba(0, 0, 0, 0.25);
6 | display: flex;
7 | justify-content: space-between;
8 | padding: 12px 24px;
9 | `;
10 |
11 | export const logo = () => css`
12 | display: flex;
13 | `;
14 |
15 | export const orderLink = () => css`
16 | display: flex;
17 | padding: 4px;
18 | `;
19 |
20 | export const signInButton = () => css`
21 | display: flex;
22 | padding: 4px;
23 | `;
24 |
--------------------------------------------------------------------------------
/src/client/components/navigators/Header/Header.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 |
3 | import { useAuthUser } from '../../../hooks/useAuthUser';
4 | import { useOpenModal } from '../../../store/modal';
5 | import { Anchor } from '../../foundation/Anchor';
6 | import { Icon } from '../../foundation/Icon';
7 | import { Image } from '../../foundation/Image';
8 |
9 | import * as styles from './Header.styles';
10 |
11 | export const Header: FC = () => {
12 | const { isAuthUser } = useAuthUser();
13 | const handleOpenModal = useOpenModal();
14 |
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 | {isAuthUser ? (
23 |
24 |
25 |
26 |
27 |
28 | ) : (
29 |
36 | )}
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/client/components/navigators/Header/index.ts:
--------------------------------------------------------------------------------
1 | export { Header } from './Header';
2 |
--------------------------------------------------------------------------------
/src/client/components/order/CartItem/CartItem.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: flex;
5 | `;
6 |
7 | export const container__mobile = () => css`
8 | flex-direction: column;
9 | gap: 4px;
10 | `;
11 |
12 | export const container__desktop = () => css`
13 | flex-direction: row;
14 | gap: 8px;
15 | `;
16 |
17 | export const item = () => css`
18 | flex-grow: 1;
19 | flex-shrink: 1;
20 | opacity: 1;
21 | transition-duration: 300ms;
22 | transition-property: opacity;
23 | transition-timing-function: linear;
24 |
25 | &:hover {
26 | opacity: 0.8;
27 | }
28 | `;
29 |
30 | export const itemInner = () => css`
31 | display: flex;
32 | gap: 8px;
33 | width: 100%;
34 | `;
35 |
36 | export const thumbnail = () => css`
37 | border-radius: 8px;
38 | overflow: hidden;
39 | position: relative;
40 | `;
41 |
42 | export const thumbnail__mobile = () => css`
43 | width: 50%;
44 | `;
45 |
46 | export const thumbnail__desktop = () => css`
47 | width: 256px;
48 | `;
49 |
50 | export const offerLabel = () => css`
51 | left: 0;
52 | margin: 4px;
53 | position: absolute;
54 | top: 0;
55 | `;
56 |
57 | export const details = () => css`
58 | display: flex;
59 | flex: 1;
60 | flex-direction: column;
61 | gap: 16px;
62 | justify-content: space-between;
63 | padding: 8px 4px;
64 | `;
65 |
66 | export const itemName = () => css`
67 | -webkit-box-orient: vertical;
68 | color: #222222;
69 | display: -webkit-box;
70 | -webkit-line-clamp: line;
71 | overflow: hidden;
72 | `;
73 |
74 | export const itemPrice = () => css`
75 | color: #222222;
76 | font-size: 0.875rem;
77 | `;
78 |
79 | export const controller = () => css`
80 | align-items: center;
81 | display: flex;
82 | gap: 16px;
83 | padding: 4px 8px;
84 | `;
85 |
86 | export const controller__desktop = () => css`
87 | display: flex;
88 | flex-direction: column;
89 | gap: 4px;
90 | justify-content: flex-start;
91 | `;
92 |
93 | export const controller__mobile = () => css`
94 | display: flex;
95 | flex-direction: row;
96 | gap: 4px;
97 | justify-content: flex-end;
98 | `;
99 |
100 | export const counter = () => css`
101 | align-items: center;
102 | display: flex;
103 | font-size: 0.75rem;
104 | `;
105 |
106 | export const counterInput = () => css`
107 | border: 1px solid rgba(0, 0, 0, 0.25);
108 | border-radius: 4px;
109 | margin-left: 4px;
110 | padding: 4px 8px;
111 | width: 64px;
112 |
113 | &::placeholder {
114 | color: #999999;
115 | }
116 | `;
117 |
--------------------------------------------------------------------------------
/src/client/components/order/CartItem/CartItem.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import * as currencyFormatter from 'currency-formatter';
3 | import type { ChangeEventHandler, FC } from 'react';
4 |
5 | import type { ShoppingCartItemFragmentResponse } from '../../../graphql/fragments';
6 | import { useActiveOffer } from '../../../hooks/useActiveOffer';
7 | import { normalizeCartItemCount } from '../../../utils/normalize_cart_item';
8 | import { Anchor } from '../../foundation/Anchor';
9 | import { AspectRatio } from '../../foundation/AspectRatio';
10 | import { DeviceType, GetDeviceType } from '../../foundation/GetDeviceType';
11 | import { Image } from '../../foundation/Image';
12 | import { OutlineButton } from '../../foundation/OutlineButton';
13 | import { ProductOfferLabel } from '../../product/ProductOfferLabel';
14 |
15 | import * as styles from './CartItem.styles';
16 |
17 | type Props = {
18 | item: ShoppingCartItemFragmentResponse;
19 | onUpdate: (productId: number, count: number) => void;
20 | onRemove: (productId: number) => void;
21 | };
22 |
23 | export const CartItem: FC = ({ item, onRemove, onUpdate }) => {
24 | const thumbnailFile = item.product.media.find((productMedia) => productMedia.isThumbnail)?.file;
25 | const { activeOffer } = useActiveOffer(item.product);
26 | const price = activeOffer?.price ?? item.product.price;
27 |
28 | const updateCount: ChangeEventHandler = (ev) => {
29 | const count = normalizeCartItemCount(ev.target.valueAsNumber || 1);
30 | onUpdate(item.product.id, count);
31 | };
32 |
33 | return (
34 |
35 | {({ deviceType }) => {
36 | return (
37 |
43 |
44 |
45 |
46 | {thumbnailFile ? (
47 |
53 |
54 |
55 |
56 | {activeOffer !== undefined && (
57 |
60 | )}
61 |
62 | ) : null}
63 |
64 |
{item.product.name}
65 |
66 | {currencyFormatter.format(price, { code: 'JPY', precision: 0 })}
67 |
68 |
69 |
70 |
71 |
72 |
78 |
89 | onRemove(item.product.id)} size="base">
90 | 削除
91 |
92 |
93 |
94 | );
95 | }}
96 |
97 | );
98 | };
99 |
--------------------------------------------------------------------------------
/src/client/components/order/CartItem/index.ts:
--------------------------------------------------------------------------------
1 | export * from './CartItem';
2 |
--------------------------------------------------------------------------------
/src/client/components/order/OrderForm/OrderForm.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: flex;
5 | flex-direction: column;
6 | gap: 12px;
7 | `;
8 |
9 | export const form = () => css`
10 | display: grid;
11 | gap: 80px;
12 | `;
13 |
14 | export const inputList = () => css`
15 | display: grid;
16 | gap: 16px;
17 | `;
18 |
19 | export const input = () => css`
20 | width: 100%;
21 | `;
22 |
23 | export const purchaseButton = () => css`
24 | justify-self: center;
25 | `;
26 |
--------------------------------------------------------------------------------
/src/client/components/order/OrderForm/OrderForm.tsx:
--------------------------------------------------------------------------------
1 | import { useFormik } from 'formik';
2 | import _ from 'lodash';
3 | import type { ChangeEventHandler, FC } from 'react';
4 | import zipcodeJa from 'zipcode-ja';
5 |
6 | import { PrimaryButton } from '../../foundation/PrimaryButton';
7 | import { TextInput } from '../../foundation/TextInput';
8 |
9 | import * as styles from './OrderForm.styles';
10 |
11 | type OrderFormValue = {
12 | zipCode: string;
13 | prefecture: string;
14 | city: string;
15 | streetAddress: string;
16 | };
17 |
18 | type Props = {
19 | onSubmit: (orderFormValue: OrderFormValue) => void;
20 | };
21 |
22 | export const OrderForm: FC = ({ onSubmit }) => {
23 | const formik = useFormik({
24 | initialValues: {
25 | city: '',
26 | prefecture: '',
27 | streetAddress: '',
28 | zipCode: '',
29 | },
30 | onSubmit,
31 | });
32 |
33 | const handleZipcodeChange: ChangeEventHandler = (event) => {
34 | formik.handleChange(event);
35 |
36 | const zipCode = event.target.value;
37 | const address = [...(_.cloneDeep(zipcodeJa)[zipCode]?.address ?? [])];
38 | const prefecture = address.shift();
39 | const city = address.join(' ');
40 |
41 | formik.setFieldValue('prefecture', prefecture);
42 | formik.setFieldValue('city', city);
43 | };
44 |
45 | return (
46 |
89 | );
90 | };
91 |
--------------------------------------------------------------------------------
/src/client/components/order/OrderForm/index.ts:
--------------------------------------------------------------------------------
1 | export * from './OrderForm';
2 |
--------------------------------------------------------------------------------
/src/client/components/order/OrderPreview/OrderPreview.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: flex;
5 | flex-direction: column;
6 | gap: 12px;
7 | `;
8 |
9 | export const itemList = () => css`
10 | display: grid;
11 | gap: 16px;
12 | width: 100%;
13 | `;
14 |
15 | export const totalPrice = () => css`
16 | border-top: 1px solid rgba(0, 0, 0, 0.25);
17 | font-size: 1.5rem;
18 | font-weight: 700;
19 | padding: 8px 0;
20 | text-align: right;
21 | width: 100%;
22 | `;
23 |
--------------------------------------------------------------------------------
/src/client/components/order/OrderPreview/OrderPreview.tsx:
--------------------------------------------------------------------------------
1 | import * as currencyFormatter from 'currency-formatter';
2 | import _ from 'lodash';
3 | import type { FC } from 'react';
4 | import { memo } from 'react';
5 |
6 | import type { OrderFragmentResponse } from '../../../graphql/fragments';
7 | import { useTotalPrice } from '../../../hooks/useTotalPrice';
8 | import { CartItem } from '../CartItem';
9 |
10 | import * as styles from './OrderPreview.styles';
11 |
12 | type Props = {
13 | order: OrderFragmentResponse;
14 | onUpdateCartItem: (productId: number, amount: number) => void;
15 | onRemoveCartItem: (productId: number) => void;
16 | };
17 |
18 | export const OrderPreview: FC = memo(({ onRemoveCartItem, onUpdateCartItem, order }) => {
19 | const { totalPrice } = useTotalPrice(order);
20 |
21 | return (
22 |
23 |
24 | {order.items.map((item) => {
25 | return (
26 | -
27 |
28 |
29 | );
30 | })}
31 |
32 |
{currencyFormatter.format(totalPrice, { code: 'JPY', precision: 0 })}
33 |
34 | );
35 | }, _.isEqual);
36 |
37 | OrderPreview.displayName = 'OrderPreview';
38 |
--------------------------------------------------------------------------------
/src/client/components/order/OrderPreview/index.ts:
--------------------------------------------------------------------------------
1 | export * from './OrderPreview';
2 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductHeroImage/ProductHeroImage.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | opacity: 1;
5 | position: relative;
6 | transition-duration: 300ms;
7 | transition-property: opacity;
8 | transition-timing-function: linear;
9 |
10 | &:hover {
11 | opacity: 0.8;
12 | }
13 | `;
14 |
15 | export const image = () => css`
16 | height: 100%;
17 | object-fit: cover;
18 | width: 100%;
19 | `;
20 |
21 | export const overlay = () => css`
22 | align-items: flex-start;
23 | background-image: linear-gradient(0deg, rgba(0, 0, 0, 0.5) 0, transparent);
24 | bottom: 0;
25 | display: flex;
26 | flex-direction: column;
27 | height: 30%;
28 | justify-content: flex-end;
29 | left: 0;
30 | padding: 16px 24px;
31 | position: absolute;
32 | right: 0;
33 | `;
34 |
35 | export const title = () => css`
36 | color: #ffffff;
37 | font-weight: 700;
38 | letter-spacing: 1px;
39 | `;
40 |
41 | export const title__desktop = () => css`
42 | font-size: 1.5rem;
43 | `;
44 |
45 | export const title__mobile = () => css`
46 | font-size: 1.125rem;
47 | `;
48 |
49 | export const description = () => css`
50 | color: #ffffff;
51 | margin-top: 8px;
52 | `;
53 |
54 | export const description__desktop = () => css`
55 | font-size: 1rem;
56 | `;
57 |
58 | export const description__mobile = () => css`
59 | font-size: 0.875rem;
60 | `;
61 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductHeroImage/ProductHeroImage.tsx:
--------------------------------------------------------------------------------
1 | import CanvasKitInit from 'canvaskit-wasm';
2 | import CanvasKitWasmUrl from 'canvaskit-wasm/bin/canvaskit.wasm?url';
3 | import classNames from 'classnames';
4 | import _ from 'lodash';
5 | import { memo, useEffect, useState } from 'react';
6 | import type { FC } from 'react';
7 |
8 | import type { ProductFragmentResponse } from '../../../graphql/fragments';
9 | import { Anchor } from '../../foundation/Anchor';
10 | import { AspectRatio } from '../../foundation/AspectRatio';
11 | import { DeviceType, GetDeviceType } from '../../foundation/GetDeviceType';
12 | import { WidthRestriction } from '../../foundation/WidthRestriction';
13 |
14 | import * as styles from './ProductHeroImage.styles';
15 |
16 | async function loadImageAsDataURL(url: string): Promise {
17 | const CanvasKit = await CanvasKitInit({
18 | // WASM ファイルの URL を渡す
19 | locateFile: () => CanvasKitWasmUrl,
20 | });
21 |
22 | // 画像を読み込む
23 | const data = await fetch(url).then((res) => res.arrayBuffer());
24 | const image = CanvasKit.MakeImageFromEncoded(data);
25 | if (image == null) {
26 | // 読み込みに失敗したとき、透明な 1x1 GIF の Data URL を返却する
27 | return 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
28 | }
29 |
30 | // 画像を Canvas に描画して Data URL を生成する
31 | const canvas = CanvasKit.MakeCanvas(image.width(), image.height());
32 | const ctx = canvas.getContext('2d');
33 | // @ts-expect-error ...
34 | ctx?.drawImage(image, 0, 0);
35 | return canvas.toDataURL();
36 | }
37 |
38 | type Props = {
39 | product: ProductFragmentResponse;
40 | title: string;
41 | };
42 |
43 | export const ProductHeroImage: FC = memo(({ product, title }) => {
44 | const thumbnailFile = product.media.find((productMedia) => productMedia.isThumbnail)?.file;
45 |
46 | const [imageDataUrl, setImageDataUrl] = useState();
47 |
48 | useEffect(() => {
49 | if (thumbnailFile == null) {
50 | return;
51 | }
52 | loadImageAsDataURL(thumbnailFile.filename).then((dataUrl) => setImageDataUrl(dataUrl));
53 | }, [thumbnailFile]);
54 |
55 | if (imageDataUrl === undefined) {
56 | return null;
57 | }
58 |
59 | return (
60 |
61 | {({ deviceType }) => {
62 | return (
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
77 | {title}
78 |
79 |
85 | {product.name}
86 |
87 |
88 |
89 |
90 |
91 | );
92 | }}
93 |
94 | );
95 | }, _.isEqual);
96 |
97 | ProductHeroImage.displayName = 'ProductHeroImage';
98 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductHeroImage/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductHeroImage';
2 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/MediaItem/MediaItem.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: flex;
5 | height: 100%;
6 | position: relative;
7 | width: 100%;
8 | `;
9 |
10 | export const playIcon = () => css`
11 | display: grid;
12 | height: 100%;
13 | inset: 0;
14 | place-items: center;
15 | position: absolute;
16 | width: 100%;
17 | `;
18 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/MediaItem/MediaItem.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useEffect, useState } from 'react';
3 |
4 | import type { MediaFileFragmentResponse } from '../../../../graphql/fragments';
5 | import { getMediaType } from '../../../../utils/get_media_type';
6 | import { Icon } from '../../../foundation/Icon';
7 | import { Image } from '../../../foundation/Image';
8 |
9 | import * as styles from './MediaItem.styles';
10 | import { loadThumbnail } from './loadThumbnail';
11 |
12 | type Props = {
13 | file: MediaFileFragmentResponse;
14 | };
15 |
16 | export const MediaItem: FC = ({ file }) => {
17 | const [imageSrc, setImageSrc] = useState();
18 | const mediaType = getMediaType(file.filename);
19 |
20 | useEffect(() => {
21 | if (mediaType === 'image') {
22 | return setImageSrc(file.filename);
23 | }
24 | loadThumbnail(file.filename).then((url) => setImageSrc(url));
25 | }, [file.filename, mediaType]);
26 |
27 | if (imageSrc === undefined) {
28 | return null;
29 | }
30 |
31 | return (
32 |
33 |
34 | {mediaType === 'video' && (
35 |
36 |
37 |
38 | )}
39 |
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/MediaItem/index.ts:
--------------------------------------------------------------------------------
1 | export * from './MediaItem';
2 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/MediaItem/loadThumbnail.ts:
--------------------------------------------------------------------------------
1 | export const loadThumbnail = async (url: string) => {
2 | const video = document.createElement('video');
3 |
4 | await new Promise((resolve) => {
5 | video.src = url;
6 | video.addEventListener('canplaythrough', resolve, { once: true });
7 | });
8 |
9 | const canvas = document.createElement('canvas');
10 | const context = canvas.getContext('2d');
11 |
12 | canvas.width = video.videoWidth;
13 | canvas.height = video.videoHeight;
14 | if (context === null) {
15 | return;
16 | }
17 |
18 | video.currentTime = 0;
19 | context.drawImage(video, 0, 0, canvas.width, canvas.height);
20 |
21 | const thumbnailData = canvas.toDataURL('image/png');
22 | return thumbnailData;
23 | };
24 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/MediaItemPreviewer/MediaItemPreiewer.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: flex;
5 | `;
6 |
7 | export const video = () => css`
8 | height: auto;
9 | object-fit: cover;
10 | width: 100%;
11 | `;
12 |
13 | export const video__mobile = () => css`
14 | max-width: 100vw;
15 | `;
16 |
17 | export const video__desktop = () => css`
18 | max-width: 1024px;
19 | `;
20 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/MediaItemPreviewer/MediaItemPreviewer.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { FC } from 'react';
3 |
4 | import type { MediaFileFragmentResponse } from '../../../../graphql/fragments';
5 | import { getMediaType } from '../../../../utils/get_media_type';
6 | import { DeviceType, GetDeviceType } from '../../../foundation/GetDeviceType';
7 | import { Image } from '../../../foundation/Image';
8 |
9 | import * as styles from './MediaItemPreiewer.styles';
10 |
11 | type Props = {
12 | file: MediaFileFragmentResponse;
13 | };
14 |
15 | export const MediaItemPreviewer: FC = ({ file }) => {
16 | const type = getMediaType(file.filename);
17 |
18 | return (
19 |
20 | {type === 'image' && }
21 | {type === 'video' && (
22 |
23 | {({ deviceType }) => (
24 |
35 | )}
36 |
37 | )}
38 |
39 | );
40 | };
41 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/MediaItemPreviewer/index.ts:
--------------------------------------------------------------------------------
1 | export * from './MediaItemPreviewer';
2 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/ProductMediaListPreviewer.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: grid;
5 | gap: 8px;
6 | overflow-x: hidden;
7 | `;
8 |
9 | export const itemListWrapper = () => css`
10 | display: grid;
11 | overflow-x: scroll;
12 | place-items: center;
13 | `;
14 |
15 | export const itemList = () => css`
16 | display: flex;
17 | gap: 8px;
18 | justify-content: center;
19 | padding: 0 24px 8px;
20 | width: fit-content;
21 | `;
22 |
23 | export const item = () => css`
24 | height: 40px;
25 | width: 40px;
26 | `;
27 |
28 | export const itemSelectButton = () => css`
29 | display: inline-flex;
30 | height: 100%;
31 | opacity: 1;
32 | pointer-events: auto;
33 | width: 100%;
34 | `;
35 |
36 | export const itemSelectButton__disabled = () => css`
37 | opacity: 0.5;
38 | pointer-events: none;
39 | `;
40 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/ProductMediaListPreviewer.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { FC } from 'react';
3 | import { useState } from 'react';
4 |
5 | import type { ProductFragmentResponse } from '../../../graphql/fragments';
6 | import { AspectRatio } from '../../foundation/AspectRatio';
7 |
8 | import { MediaItem } from './MediaItem';
9 | import { MediaItemPreviewer } from './MediaItemPreviewer';
10 | import * as styles from './ProductMediaListPreviewer.styles';
11 |
12 | type Props = {
13 | product: ProductFragmentResponse | undefined;
14 | };
15 |
16 | export const ProductMediaListPreviewer: FC = ({ product }) => {
17 | const [activeIndex, setActiveIndex] = useState(0);
18 |
19 | if (product === undefined || product.media.length === 0) {
20 | return null;
21 | }
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
29 |
30 | {product.media.map((media, index) => {
31 | const disabled = index === activeIndex;
32 |
33 | return (
34 | -
35 |
36 |
45 |
46 |
47 | );
48 | })}
49 |
50 |
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductMediaListPreviewer/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductMediaListPreviewer';
2 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductOfferLabel/ProductOfferLabel.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | background-color: #ff6347;
5 | border-radius: 8px;
6 | color: #ffffff;
7 | font-weight: 700;
8 | `;
9 |
10 | export const container__base = () => css`
11 | font-size: 0.75rem;
12 | padding: 4px 8px;
13 | `;
14 |
15 | export const container__lg = () => css`
16 | font-size: 0.875rem;
17 | padding: 8px 24px;
18 | `;
19 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductOfferLabel/ProductOfferLabel.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { FC, ReactNode } from 'react';
3 |
4 | import * as styles from './ProductOfferLabel.styles';
5 |
6 | type Size = 'base' | 'lg';
7 | type Props = {
8 | children: ReactNode;
9 | size: Size;
10 | };
11 |
12 | export const ProductOfferLabel: FC = ({ children, size }) => (
13 |
19 | {children}
20 |
21 | );
22 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductOfferLabel/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductOfferLabel';
2 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductOverview/ProductOverview.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const offerLabel = () => css`
4 | margin-bottom: 16px;
5 | `;
6 |
7 | export const container = () => css`
8 | align-items: flex-start;
9 | display: flex;
10 | flex-direction: column;
11 | `;
12 |
13 | export const productName = () => css`
14 | font-size: 1.5rem;
15 | font-weight: 700;
16 | `;
17 |
18 | export const productDescription = () => css`
19 | font-size: 0.875rem;
20 | margin-top: 8px;
21 | `;
22 |
23 | export const priceWrapper = () => css`
24 | align-items: flex-start;
25 | align-self: flex-end;
26 | display: flex;
27 | flex-direction: column;
28 | margin-top: 24px;
29 | `;
30 |
31 | export const priceWithoutOffer = () => css`
32 | align-self: flex-end;
33 | margin-bottom: 4px;
34 | text-decoration: line-through;
35 | `;
36 |
37 | export const price = () => css`
38 | align-self: flex-end;
39 | font-size: 1.5rem;
40 | font-weight: 700;
41 | `;
42 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductOverview/ProductOverview.tsx:
--------------------------------------------------------------------------------
1 | import * as currencyFormatter from 'currency-formatter';
2 | import _ from 'lodash';
3 | import type { FC } from 'react';
4 | import { memo } from 'react';
5 |
6 | import type { LimitedTimeOfferFragmentResponse, ProductFragmentResponse } from '../../../graphql/fragments';
7 | import { ProductOfferLabel } from '../ProductOfferLabel';
8 |
9 | import * as styles from './ProductOverview.styles';
10 |
11 | type Props = {
12 | product: ProductFragmentResponse | undefined;
13 | activeOffer: LimitedTimeOfferFragmentResponse | undefined;
14 | };
15 |
16 | export const ProductOverview: FC = memo(({ activeOffer, product }) => {
17 | if (product === undefined) {
18 | return null;
19 | }
20 |
21 | const renderActiveOffer = () => {
22 | if (activeOffer === undefined) {
23 | return;
24 | }
25 |
26 | const endTime = window.Temporal.Instant.from(activeOffer.endDate).toLocaleString('ja-jp', {
27 | day: '2-digit',
28 | hour: '2-digit',
29 | minute: '2-digit',
30 | month: '2-digit',
31 | second: '2-digit',
32 | year: 'numeric',
33 | });
34 |
35 | return (
36 |
37 |
38 | までタイムセール
39 |
40 |
41 | );
42 | };
43 |
44 | return (
45 |
46 | {renderActiveOffer()}
47 |
{product.name}
48 |
{product.description}
49 |
50 |
51 | {activeOffer !== undefined ? (
52 |
53 | {currencyFormatter.format(product.price, { code: 'JPY', precision: 0 })}
54 |
55 | ) : null}
56 |
57 | {currencyFormatter.format(activeOffer?.price ?? product.price, { code: 'JPY', precision: 0 })}
58 |
59 |
60 |
61 | );
62 | }, _.isEqual);
63 |
64 | ProductOverview.displayName = 'ProductOverview';
65 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductOverview/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductOverview';
2 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductPurchaseSeciton/ProductPurchaseSection.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: flex;
5 | flex-direction: column;
6 | `;
7 |
8 | export const amount = () => css`
9 | align-items: center;
10 | display: flex;
11 | font-size: 0.875rem;
12 | gap: 4px;
13 | justify-content: flex-end;
14 | line-height: 24px;
15 | `;
16 |
17 | export const checkIcon = () => css`
18 | display: inline-flex;
19 | `;
20 |
21 | export const actionButtonList = () => css`
22 | display: flex;
23 | gap: 8px;
24 | margin-top: 8px;
25 | `;
26 |
27 | export const signInWrapper = () => css`
28 | align-items: flex-end;
29 | display: flex;
30 | flex-direction: column;
31 | gap: 4px;
32 | `;
33 |
34 | export const signIn = () => css`
35 | font-size: 0.875rem;
36 | `;
37 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductPurchaseSeciton/ProductPurchaseSection.tsx:
--------------------------------------------------------------------------------
1 | import _ from 'lodash';
2 | import type { FC } from 'react';
3 | import { memo } from 'react';
4 |
5 | import type { ProductFragmentResponse } from '../../../graphql/fragments';
6 | import { Icon } from '../../foundation/Icon';
7 | import { OutlineButton } from '../../foundation/OutlineButton';
8 | import { PrimaryAnchor } from '../../foundation/PrimaryAnchor';
9 | import { PrimaryButton } from '../../foundation/PrimaryButton';
10 |
11 | import * as styles from './ProductPurchaseSection.styles';
12 |
13 | type Props = {
14 | product: ProductFragmentResponse | undefined;
15 | amountInCart: number;
16 | isAuthUser: boolean;
17 | onUpdateCartItem: (productId: number, count: number) => void;
18 | onOpenSignInModal: () => void;
19 | };
20 |
21 | export const ProductPurchaseSection: FC = memo(
22 | ({ amountInCart, isAuthUser, onOpenSignInModal, onUpdateCartItem, product }) => {
23 | if (product === undefined) {
24 | return null;
25 | }
26 |
27 | if (!isAuthUser) {
28 | return (
29 |
30 |
31 |
購入にはログインが必要です
32 |
onOpenSignInModal()} size="sm">
33 | ログイン
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | if (amountInCart === 0) {
41 | return (
42 |
43 |
onUpdateCartItem(product.id, 1)} size="sm">
44 | カートに追加
45 |
46 |
47 | );
48 | }
49 |
50 | return (
51 |
52 |
53 |
54 |
55 |
56 | {amountInCart}個 カートに追加済み
57 |
58 |
59 |
60 | 購入手続きへ
61 |
62 |
onUpdateCartItem(product.id, amountInCart + 1)} size="lg">
63 | カートに追加
64 |
65 |
66 |
67 | );
68 | },
69 | _.isEqual,
70 | );
71 |
72 | ProductPurchaseSection.displayName = 'ProductPurchaseSection';
73 |
--------------------------------------------------------------------------------
/src/client/components/product/ProductPurchaseSeciton/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductPurchaseSection';
2 |
--------------------------------------------------------------------------------
/src/client/components/review/ReviewList/ReviewList.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const itemList = () => css`
4 | display: grid;
5 | gap: 16px;
6 | `;
7 |
8 | export const item = () => css`
9 | align-items: center;
10 | display: flex;
11 | gap: 8px;
12 | padding: 4px 0;
13 | `;
14 |
15 | export const avaterImage = () => css`
16 | border-radius: 50%;
17 | overflow: hidden;
18 | `;
19 |
20 | export const content = () => css`
21 | display: grid;
22 | flex: 1;
23 | `;
24 |
25 | export const time = () => css`
26 | color: #999999;
27 | font-size: 0.75rem;
28 | `;
29 |
30 | export const comment = () => css`
31 | color: #222222;
32 | font-size: 0.875rem;
33 | line-height: 20px;
34 | min-height: 20px;
35 | `;
36 |
--------------------------------------------------------------------------------
/src/client/components/review/ReviewList/ReviewList.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 |
3 | import type { ReviewFragmentResponse } from '../../../graphql/fragments';
4 | import { AspectRatio } from '../../foundation/AspectRatio';
5 | import { Image } from '../../foundation/Image';
6 |
7 | import * as styles from './ReviewList.styles';
8 |
9 | type Props = {
10 | reviews: ReviewFragmentResponse[];
11 | };
12 |
13 | export const ReviewList: FC = ({ reviews }) => {
14 | if (reviews.length === 0) {
15 | return null;
16 | }
17 |
18 | return (
19 |
20 | {reviews.map((review) => {
21 | const endTime = window.Temporal.Instant.from(review.postedAt).toLocaleString('ja-jp', {
22 | day: '2-digit',
23 | hour: '2-digit',
24 | minute: '2-digit',
25 | month: '2-digit',
26 | second: '2-digit',
27 | year: 'numeric',
28 | });
29 |
30 | return (
31 | -
32 |
37 |
38 |
39 |
{review.comment}
40 |
41 |
42 | );
43 | })}
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/src/client/components/review/ReviewList/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ReviewList';
2 |
--------------------------------------------------------------------------------
/src/client/components/review/ReviewSection/ReviewSection.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const form = () => css`
4 | display: grid;
5 | gap: 24px;
6 | margin-top: 40px;
7 | `;
8 |
9 | export const commentTextAreaWrapper = () => css`
10 | display: grid;
11 | gap: 4px;
12 | `;
13 |
14 | export const submitButton = () => css`
15 | justify-self: center;
16 | `;
17 |
18 | export const error = () => css`
19 | color: #b00020;
20 | font-size: 0.875rem;
21 | line-height: 1;
22 | min-height: 1em;
23 | `;
24 |
--------------------------------------------------------------------------------
/src/client/components/review/ReviewSection/ReviewSection.tsx:
--------------------------------------------------------------------------------
1 | import type { FormikErrors } from 'formik';
2 | import { useFormik } from 'formik';
3 | import _ from 'lodash';
4 | import type { FC } from 'react';
5 | import { memo } from 'react';
6 | import * as z from 'zod';
7 |
8 | import type { ReviewFragmentResponse } from '../../../graphql/fragments';
9 | import { PrimaryButton } from '../../foundation/PrimaryButton';
10 | import { TextArea } from '../../foundation/TextArea';
11 | import { ReviewList } from '../ReviewList';
12 |
13 | import * as styles from './ReviewSection.styles';
14 |
15 | const LESS_THAN_64_LENGTH_REGEX = /^([\s\S\n]{0,8}){0,8}$/u;
16 | // NOTE: 改行含めて 64 文字以内であるかどうか確認する
17 | const commentSchema = z.string().regex(LESS_THAN_64_LENGTH_REGEX);
18 |
19 | type Props = {
20 | reviews: ReviewFragmentResponse[] | undefined;
21 | hasSignedIn: boolean;
22 | onSubmitReview: (reviewForm: ReviewForm) => void;
23 | };
24 |
25 | type ReviewForm = {
26 | comment: string;
27 | };
28 |
29 | export const ReviewSection: FC = memo(({ hasSignedIn, onSubmitReview, reviews }) => {
30 | const formik = useFormik({
31 | initialValues: {
32 | comment: '',
33 | },
34 | async onSubmit(value, { resetForm }) {
35 | onSubmitReview(value);
36 | resetForm();
37 | },
38 | validate(values) {
39 | const errors: FormikErrors = {};
40 | if (values.comment != '' && !commentSchema.safeParse(values.comment).success) {
41 | errors['comment'] = '64 文字以内でコメントしてください';
42 | }
43 | return errors;
44 | },
45 | validateOnChange: true,
46 | });
47 |
48 | return (
49 |
50 | {reviews != null ?
: null}
51 | {hasSignedIn && (
52 |
71 | )}
72 |
73 | );
74 | }, _.isEqual);
75 |
76 | ReviewSection.displayName = 'ReviewSection';
77 |
--------------------------------------------------------------------------------
/src/client/components/review/ReviewSection/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ReviewSection';
2 |
--------------------------------------------------------------------------------
/src/client/global.styles.ts:
--------------------------------------------------------------------------------
1 | import { injectGlobal as css } from '@emotion/css';
2 | import resetCssText from 'modern-css-reset/src/reset.css?raw';
3 |
4 | export const injectGlobalStyle = () => css`
5 | ${resetCssText}
6 |
7 | ul, ol {
8 | list-style: none;
9 | margin: 0;
10 | padding: 0;
11 | }
12 |
13 | body {
14 | font-family: sans-serif;
15 | }
16 |
17 | button {
18 | appearance: none;
19 | background-color: transparent;
20 | border: none;
21 | cursor: pointer;
22 | margin: 0;
23 | padding: 0;
24 | }
25 | `;
26 |
--------------------------------------------------------------------------------
/src/client/graphql/mutations.ts:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client';
2 |
3 | export const SignInMutation = gql`
4 | mutation SignIn($email: String!, $password: String!) {
5 | signin(email: $email, password: $password)
6 | }
7 | `;
8 | export type SignInMutationResponse = boolean;
9 |
10 | export const SignUpMutation = gql`
11 | mutation SignUp($email: String!, $name: String!, $password: String!) {
12 | signup(email: $email, name: $name, password: $password)
13 | }
14 | `;
15 | export type SignUpMutationResponse = boolean;
16 |
17 | export const SendReviewMutation = gql`
18 | mutation SendReview($productId: Int!, $comment: String!) {
19 | sendReview(productId: $productId, comment: $comment)
20 | }
21 | `;
22 | export type SendReviewMutationResponse = boolean;
23 |
24 | export const UpdateItemInShoppingCartMutation = gql`
25 | mutation UpdateItemInShoppingCart($productId: Int!, $amount: Int!) {
26 | updateItemInShoppingCart(productId: $productId, amount: $amount)
27 | }
28 | `;
29 | export type UpdateItemInShoppingCartMutationResponse = boolean;
30 |
31 | export const OrderItemsInShoppingCartMutation = gql`
32 | mutation OrderItemsInShoppingCart($zipCode: String!, $address: String!) {
33 | orderItemsInShoppingCart(zipCode: $zipCode, address: $address)
34 | }
35 | `;
36 | export type OrderItemsInShoppingCartMutationResponse = boolean;
37 |
--------------------------------------------------------------------------------
/src/client/graphql/queries.ts:
--------------------------------------------------------------------------------
1 | import { gql } from '@apollo/client';
2 |
3 | import type {
4 | AuthUserFragmentResponse,
5 | FeatureSectionFragmentResponse,
6 | ProductReviewFragmentResponse,
7 | ProductWithReviewFragmentResponse,
8 | RecommendationFragmentResponse,
9 | } from './fragments';
10 | import {
11 | AuthUserFragment,
12 | FeatureSectionFragment,
13 | ProductReviewFragment,
14 | ProductWithReviewFragment,
15 | RecommendationFragment,
16 | } from './fragments';
17 |
18 | export const GetAuthUserQuery = gql`
19 | ${AuthUserFragment}
20 |
21 | query GetAuthUser {
22 | me {
23 | ...AuthUserFragment
24 | }
25 | }
26 | `;
27 | export type GetUserAuthQueryResponse = {
28 | me: AuthUserFragmentResponse | null;
29 | };
30 |
31 | export const GetProductReviewsQuery = gql`
32 | ${ProductReviewFragment}
33 |
34 | query GetProductReviews($productId: Int!) {
35 | product(id: $productId) {
36 | ...ProductReviewFragment
37 | }
38 | }
39 | `;
40 | export type GetProductReviewsQueryResponse = {
41 | product: ProductReviewFragmentResponse;
42 | };
43 |
44 | export const GetProductDetailsQuery = gql`
45 | ${ProductWithReviewFragment}
46 |
47 | query GetProductDetails($productId: Int!) {
48 | product(id: $productId) {
49 | ...ProductWithReviewFragment
50 | }
51 | }
52 | `;
53 | export type GetProductDetailsQueryResponse = {
54 | product: ProductWithReviewFragmentResponse;
55 | };
56 |
57 | export const GetRecommendationsQuery = gql`
58 | ${RecommendationFragment}
59 |
60 | query GetRecommendations {
61 | recommendations {
62 | ...RecommendationFragment
63 | }
64 | }
65 | `;
66 | export type GetRecommendationsQueryResponse = {
67 | recommendations: RecommendationFragmentResponse[];
68 | };
69 |
70 | export const GetFeatureSectionsQuery = gql`
71 | ${FeatureSectionFragment}
72 |
73 | query GetFeatureSections {
74 | features {
75 | ...FeatureSectionFragment
76 | }
77 | }
78 | `;
79 | export type GetFeatureSectionsQueryResponse = {
80 | features: FeatureSectionFragmentResponse[];
81 | };
82 |
--------------------------------------------------------------------------------
/src/client/hooks/useActiveOffer.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import type { LimitedTimeOfferFragmentResponse, ProductFragmentResponse } from '../graphql/fragments';
4 | import { getActiveOffer } from '../utils/get_active_offer';
5 |
6 | export function useActiveOffer(product: ProductFragmentResponse | undefined) {
7 | const [activeOffer, setActiveOffer] = useState(undefined);
8 |
9 | useEffect(() => {
10 | const timer = setInterval(() => {
11 | if (!product) {
12 | setActiveOffer(undefined);
13 | return;
14 | }
15 |
16 | const offer = getActiveOffer(product.offers);
17 | setActiveOffer(offer);
18 | }, 0);
19 |
20 | return () => {
21 | clearInterval(timer);
22 | };
23 | }, [product]);
24 |
25 | return { activeOffer };
26 | }
27 |
--------------------------------------------------------------------------------
/src/client/hooks/useAmountInCart.ts:
--------------------------------------------------------------------------------
1 | import { useAuthUser } from './useAuthUser';
2 |
3 | export const useAmountInCart = (productId: number) => {
4 | const { authUser } = useAuthUser();
5 |
6 | const order = authUser?.orders.find((order) => order.isOrdered === false);
7 | const shoppingCartItems = order?.items ?? [];
8 | const amountInCart = shoppingCartItems.find((item) => item.product.id === productId)?.amount ?? 0;
9 |
10 | return { amountInCart };
11 | };
12 |
--------------------------------------------------------------------------------
/src/client/hooks/useAuthUser.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client';
2 |
3 | import type { GetUserAuthQueryResponse } from '../graphql/queries';
4 | import { GetAuthUserQuery } from '../graphql/queries';
5 |
6 | export const useAuthUser = () => {
7 | const authUserResult = useQuery(GetAuthUserQuery);
8 | const authUser = authUserResult.data?.me;
9 | const authUserLoading = authUserResult.loading;
10 | const isAuthUser = !!authUser;
11 |
12 | return { authUser, authUserLoading, isAuthUser };
13 | };
14 |
--------------------------------------------------------------------------------
/src/client/hooks/useFeatures.ts:
--------------------------------------------------------------------------------
1 | import { useSuspenseQuery_experimental as useSuspenseQuery } from '@apollo/client';
2 |
3 | import type { GetFeatureSectionsQueryResponse } from '../graphql/queries';
4 | import { GetFeatureSectionsQuery } from '../graphql/queries';
5 |
6 | export const useFeatures = () => {
7 | const featuresResult = useSuspenseQuery(GetFeatureSectionsQuery);
8 |
9 | const features = featuresResult.data?.features;
10 |
11 | return { features };
12 | };
13 |
--------------------------------------------------------------------------------
/src/client/hooks/useOrder.ts:
--------------------------------------------------------------------------------
1 | import { useAuthUser } from './useAuthUser';
2 |
3 | export const useOrder = () => {
4 | const { authUser } = useAuthUser();
5 | const order = authUser?.orders.find((order) => order.isOrdered === false);
6 |
7 | return { order };
8 | };
9 |
--------------------------------------------------------------------------------
/src/client/hooks/useProduct.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@apollo/client';
2 | import { useErrorHandler } from 'react-error-boundary';
3 |
4 | import type { GetProductDetailsQueryResponse } from '../graphql/queries';
5 | import { GetProductDetailsQuery } from '../graphql/queries';
6 |
7 | export const useProduct = (productId: number) => {
8 | const handleError = useErrorHandler();
9 | const productResult = useQuery(GetProductDetailsQuery, {
10 | onError: handleError,
11 | variables: {
12 | productId,
13 | },
14 | });
15 |
16 | const product = productResult.data?.product;
17 |
18 | return { product };
19 | };
20 |
--------------------------------------------------------------------------------
/src/client/hooks/useRecommendation.ts:
--------------------------------------------------------------------------------
1 | import { useSuspenseQuery_experimental as useSuspenseQuery } from '@apollo/client';
2 |
3 | import type { GetRecommendationsQueryResponse } from '../graphql/queries';
4 | import { GetRecommendationsQuery } from '../graphql/queries';
5 |
6 | export const useRecommendation = () => {
7 | const recommendationsResult = useSuspenseQuery(GetRecommendationsQuery);
8 |
9 | const hour = window.Temporal.Now.plainTimeISO().hour;
10 | const recommendations = recommendationsResult?.data?.recommendations;
11 |
12 | if (recommendations == null) {
13 | return { recommendation: undefined };
14 | }
15 |
16 | const recommendation = recommendations[hour % recommendations.length];
17 | return { recommendation };
18 | };
19 |
--------------------------------------------------------------------------------
/src/client/hooks/useReviews.ts:
--------------------------------------------------------------------------------
1 | import { useLazyQuery } from '@apollo/client';
2 | import { useEffect } from 'react';
3 | import { useErrorHandler } from 'react-error-boundary';
4 |
5 | import type { GetProductReviewsQueryResponse } from '../graphql/queries';
6 | import { GetProductReviewsQuery } from '../graphql/queries';
7 |
8 | export const useReviews = (productId: number | undefined) => {
9 | const handleError = useErrorHandler();
10 |
11 | const [loadReviews, reviewsResult] = useLazyQuery(GetProductReviewsQuery, {
12 | onError: handleError,
13 | variables: {
14 | productId,
15 | },
16 | });
17 |
18 | useEffect(() => {
19 | // サーバー負荷が懸念されそうなので、リクエストを少し待つ
20 | // サーバー負荷がなくなれば、すぐ読み込んでもよい
21 | const timer = setTimeout(() => {
22 | loadReviews();
23 | }, 1000);
24 |
25 | return () => {
26 | clearTimeout(timer);
27 | };
28 | }, [loadReviews, productId]);
29 |
30 | const reviews = reviewsResult.data?.product.reviews;
31 |
32 | return { reviews };
33 | };
34 |
--------------------------------------------------------------------------------
/src/client/hooks/useSendReview.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@apollo/client';
2 | import { useErrorHandler } from 'react-error-boundary';
3 |
4 | import type { SendReviewMutationResponse } from '../graphql/mutations';
5 | import { SendReviewMutation } from '../graphql/mutations';
6 |
7 | export const useSendReview = () => {
8 | const handleError = useErrorHandler();
9 | const [sendReview] = useMutation(SendReviewMutation, {
10 | onError: handleError,
11 | onQueryUpdated(observableQuery) {
12 | return observableQuery.refetch();
13 | },
14 | refetchQueries: ['GetProductDetails'],
15 | });
16 |
17 | return { sendReview };
18 | };
19 |
--------------------------------------------------------------------------------
/src/client/hooks/useSignIn.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@apollo/client';
2 |
3 | import { SignInMutation } from '../graphql/mutations';
4 |
5 | export const useSignIn = () => {
6 | const [signIn] = useMutation(SignInMutation, {
7 | onQueryUpdated(observableQuery) {
8 | return observableQuery.refetch();
9 | },
10 | refetchQueries: ['GetAuthUser'],
11 | });
12 |
13 | return { signIn };
14 | };
15 |
--------------------------------------------------------------------------------
/src/client/hooks/useSignUp.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@apollo/client';
2 |
3 | import { SignUpMutation } from '../graphql/mutations';
4 |
5 | export const useSignUp = () => {
6 | const [signUp] = useMutation(SignUpMutation, {
7 | onQueryUpdated(observableQuery) {
8 | return observableQuery.refetch();
9 | },
10 | refetchQueries: ['GetAuthUser'],
11 | });
12 |
13 | return { signUp };
14 | };
15 |
--------------------------------------------------------------------------------
/src/client/hooks/useSubmitOrder.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@apollo/client';
2 | import { useErrorHandler } from 'react-error-boundary';
3 |
4 | import type { OrderItemsInShoppingCartMutationResponse } from '../graphql/mutations';
5 | import { OrderItemsInShoppingCartMutation } from '../graphql/mutations';
6 |
7 | export const useSubmitOrder = () => {
8 | const handleError = useErrorHandler();
9 | const [submitOrder] = useMutation(OrderItemsInShoppingCartMutation, {
10 | onError: handleError,
11 | onQueryUpdated(observableQuery) {
12 | return observableQuery.refetch();
13 | },
14 | refetchQueries: ['GetAuthUser'],
15 | });
16 |
17 | return { submitOrder };
18 | };
19 |
--------------------------------------------------------------------------------
/src/client/hooks/useTotalPrice.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import type { OrderFragmentResponse } from '../graphql/fragments';
4 | import { getActiveOffer } from '../utils/get_active_offer';
5 |
6 | export function useTotalPrice(order: OrderFragmentResponse) {
7 | const [totalPrice, setTotalPrice] = useState(0);
8 |
9 | useEffect(() => {
10 | let timer = (function tick() {
11 | return setImmediate(() => {
12 | let total = 0;
13 | for (const item of order.items) {
14 | const offer = getActiveOffer(item.product.offers);
15 | const price = offer?.price ?? item.product.price;
16 | total += price * item.amount;
17 | }
18 | setTotalPrice(total);
19 | timer = tick();
20 | });
21 | })();
22 |
23 | return () => {
24 | clearImmediate(timer);
25 | };
26 | }, [order]);
27 |
28 | return { totalPrice };
29 | }
30 |
--------------------------------------------------------------------------------
/src/client/hooks/useUpdateCartItems.ts:
--------------------------------------------------------------------------------
1 | import { useMutation } from '@apollo/client';
2 | import { useErrorHandler } from 'react-error-boundary';
3 |
4 | import type { UpdateItemInShoppingCartMutationResponse } from '../graphql/mutations';
5 | import { UpdateItemInShoppingCartMutation } from '../graphql/mutations';
6 |
7 | export const useUpdateCartItem = () => {
8 | const handleError = useErrorHandler();
9 | const [updateCartItem] = useMutation(UpdateItemInShoppingCartMutation, {
10 | onError: handleError,
11 | onQueryUpdated(observableQuery) {
12 | return observableQuery.refetch();
13 | },
14 | refetchQueries: ['GetAuthUser'],
15 | });
16 |
17 | return { updateCartItem };
18 | };
19 |
--------------------------------------------------------------------------------
/src/client/index.tsx:
--------------------------------------------------------------------------------
1 | import './polyfill/install';
2 |
3 | import { StrictMode } from 'react';
4 | import * as ReactDOM from 'react-dom/client';
5 |
6 | import { App } from './components/application/App';
7 | import { injectGlobalStyle } from './global.styles';
8 |
9 | injectGlobalStyle();
10 |
11 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
12 |
13 |
14 | ,
15 | );
16 |
--------------------------------------------------------------------------------
/src/client/pages/Fallback/Fallback.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: grid;
5 | height: 50vh;
6 | place-items: center;
7 | `;
8 |
9 | export const inner = () => css`
10 | display: grid;
11 | gap: 16px;
12 | place-items: center;
13 | `;
14 |
15 | export const mainParagraph = () => css`
16 | color: #222222;
17 | font-size: 1.5rem;
18 | font-weight: 700;
19 | `;
20 |
21 | export const subParagraph = () => css`
22 | color: #222222;
23 | font-size: 0.875rem;
24 | `;
25 |
--------------------------------------------------------------------------------
/src/client/pages/Fallback/Fallback.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { Helmet } from 'react-helmet';
3 |
4 | import { Layout } from '../../components/application/Layout';
5 |
6 | import * as styles from './Fallback.styles';
7 |
8 | export const Fallback: FC = () => (
9 | <>
10 |
11 | エラーが発生しました
12 |
13 |
14 |
15 |
16 |
エラーが発生しました
17 |
Some error has occurred
18 |
19 |
20 |
21 | >
22 | );
23 |
--------------------------------------------------------------------------------
/src/client/pages/Fallback/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Fallback';
2 |
--------------------------------------------------------------------------------
/src/client/pages/NotFound/NotFound.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: grid;
5 | height: 50vh;
6 | place-items: center;
7 | `;
8 |
9 | export const inner = () => css`
10 | display: grid;
11 | gap: 16px;
12 | place-items: center;
13 | `;
14 |
15 | export const mainParagraph = () => css`
16 | color: #222222;
17 | font-family: 'Noto Serif JP', sans-serif;
18 | font-size: 1.5rem;
19 | font-weight: 700;
20 | `;
21 |
22 | export const subParagraph = () => css`
23 | color: #222222;
24 | font-family: 'Noto Serif JP', sans-serif;
25 | font-size: 0.875rem;
26 | `;
27 |
--------------------------------------------------------------------------------
/src/client/pages/NotFound/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { useEffect, useState } from 'react';
3 | import { Helmet } from 'react-helmet';
4 |
5 | import { Layout } from '../../components/application/Layout';
6 | import { loadFonts } from '../../utils/load_fonts';
7 |
8 | import * as styles from './NotFound.styles';
9 |
10 | export const NotFound: FC = () => {
11 | const [isReady, setIsReady] = useState(false);
12 |
13 | useEffect(() => {
14 | const load = async () => {
15 | await loadFonts();
16 | setIsReady(true);
17 | };
18 |
19 | load();
20 | }, []);
21 |
22 | if (!isReady) {
23 | return null;
24 | }
25 |
26 | return (
27 | <>
28 |
29 | ページが見つかりませんでした
30 |
31 |
32 |
33 |
34 |
ページが存在しません
35 |
Not Found
36 |
37 |
38 |
39 | >
40 | );
41 | };
42 |
--------------------------------------------------------------------------------
/src/client/pages/NotFound/index.ts:
--------------------------------------------------------------------------------
1 | export * from './NotFound';
2 |
--------------------------------------------------------------------------------
/src/client/pages/Order/Order.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | padding: 24px 16px;
5 | `;
6 |
7 | export const cart = () => css`
8 | display: flex;
9 | flex-direction: column;
10 | gap: 24px;
11 | `;
12 |
13 | export const cartHeading = () => css`
14 | font-size: 1.5rem;
15 | font-weight: 700;
16 | `;
17 |
18 | export const addressForm = () => css`
19 | display: flex;
20 | flex-direction: column;
21 | gap: 24px;
22 | margin-top: 24px;
23 | `;
24 |
25 | export const addressFormHeading = () => css`
26 | font-size: 1.5rem;
27 | font-weight: 700;
28 | `;
29 |
30 | export const emptyContainer = () => css`
31 | padding: 24px 16px;
32 | `;
33 |
34 | export const emptyDescription = () => css`
35 | color: #222222;
36 | font-size: 0.875rem;
37 | padding: 80px 0;
38 | text-align: center;
39 | `;
40 |
--------------------------------------------------------------------------------
/src/client/pages/Order/Order.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { Helmet } from 'react-helmet';
3 | import { useNavigate } from 'react-router-dom';
4 |
5 | import { Layout } from '../../components/application/Layout';
6 | import { WidthRestriction } from '../../components/foundation/WidthRestriction';
7 | import { OrderForm } from '../../components/order/OrderForm';
8 | import { OrderPreview } from '../../components/order/OrderPreview';
9 | import { useAuthUser } from '../../hooks/useAuthUser';
10 | import { useOrder } from '../../hooks/useOrder';
11 | import { useSubmitOrder } from '../../hooks/useSubmitOrder';
12 | import { useUpdateCartItem } from '../../hooks/useUpdateCartItems';
13 |
14 | import * as styles from './Order.styles';
15 |
16 | export const Order: FC = () => {
17 | const navigate = useNavigate();
18 |
19 | const { authUser, authUserLoading, isAuthUser } = useAuthUser();
20 | const { updateCartItem } = useUpdateCartItem();
21 | const { submitOrder } = useSubmitOrder();
22 | const { order } = useOrder();
23 |
24 | if (authUserLoading) {
25 | return null;
26 | }
27 | if (!isAuthUser) {
28 | navigate('/');
29 | return null;
30 | }
31 |
32 | const renderContents = () => {
33 | if (!authUser || order == undefined || order.items.length === 0) {
34 | return (
35 |
38 | );
39 | }
40 |
41 | return (
42 |
43 |
44 |
カート
45 | {
47 | updateCartItem({
48 | variables: {
49 | amount: 0,
50 | productId,
51 | },
52 | });
53 | }}
54 | onUpdateCartItem={(productId, amount) => {
55 | updateCartItem({
56 | variables: {
57 | amount,
58 | productId,
59 | },
60 | });
61 | }}
62 | order={order}
63 | />
64 |
65 |
66 |
67 |
お届け先
68 | {
70 | submitOrder({
71 | variables: {
72 | address: `${values.prefecture}${values.city}${values.streetAddress}`,
73 | zipCode: values.zipCode,
74 | },
75 | }).then(() => {
76 | navigate('/order/complete');
77 | });
78 | }}
79 | />
80 |
81 |
82 | );
83 | };
84 |
85 | return (
86 | <>
87 |
88 | 購入手続き
89 |
90 |
91 | {renderContents()}
92 |
93 | >
94 | );
95 | };
96 |
--------------------------------------------------------------------------------
/src/client/pages/Order/index.ts:
--------------------------------------------------------------------------------
1 | export { Order } from './Order';
2 |
--------------------------------------------------------------------------------
/src/client/pages/OrderComplete/OrderComplete.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: grid;
5 | gap: 8px;
6 | padding: 24px 16px;
7 | `;
8 |
9 | export const notice = () => css`
10 | display: flex;
11 | flex-direction: column;
12 | gap: 24px;
13 | `;
14 |
15 | export const noticeHeading = () => css`
16 | font-size: 1.5rem;
17 | font-weight: 700;
18 | `;
19 |
20 | export const noticeDescriptionWrapper = () => css`
21 | display: grid;
22 | height: 100%;
23 | place-items: center;
24 | `;
25 |
26 | export const noticeDescription = () => css`
27 | font-family: 'Noto Serif JP', sans-serif;
28 | text-align: center;
29 | `;
30 |
31 | export const noticeDescription__desktop = () => css`
32 | font-size: 1.125rem;
33 | `;
34 |
35 | export const noticeDescription__mobile = () => css`
36 | font-size: 1rem;
37 | `;
38 |
39 | export const recommended = () => css`
40 | display: flex;
41 | flex-direction: column;
42 | gap: 24px;
43 | `;
44 |
45 | export const recommendedHeading = () => css`
46 | font-size: 1.5rem;
47 | font-weight: 700;
48 | `;
49 |
50 | export const backToTopButtonWrapper = () => css`
51 | margin-top: 40px;
52 | text-align: center;
53 | `;
54 |
--------------------------------------------------------------------------------
/src/client/pages/OrderComplete/OrderComplete.tsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 | import type { FC } from 'react';
3 | import { useEffect, useState } from 'react';
4 | import { Helmet } from 'react-helmet';
5 | import { useNavigate } from 'react-router-dom';
6 |
7 | import { Layout } from '../../components/application/Layout';
8 | import { AspectRatio } from '../../components/foundation/AspectRatio';
9 | import { DeviceType, GetDeviceType } from '../../components/foundation/GetDeviceType';
10 | import { PrimaryAnchor } from '../../components/foundation/PrimaryAnchor';
11 | import { WidthRestriction } from '../../components/foundation/WidthRestriction';
12 | import { ProductHeroImage } from '../../components/product/ProductHeroImage';
13 | import { useAuthUser } from '../../hooks/useAuthUser';
14 | import { useRecommendation } from '../../hooks/useRecommendation';
15 | import { loadFonts } from '../../utils/load_fonts';
16 |
17 | import * as styles from './OrderComplete.styles';
18 |
19 | export const OrderComplete: FC = () => {
20 | const navigate = useNavigate();
21 | const [isReadyFont, setIsReadyFont] = useState(false);
22 | const { authUserLoading, isAuthUser } = useAuthUser();
23 | const { recommendation } = useRecommendation();
24 |
25 | useEffect(() => {
26 | loadFonts().then(() => {
27 | setIsReadyFont(true);
28 | });
29 | }, []);
30 |
31 | if (!recommendation || !isReadyFont || authUserLoading) {
32 | return null;
33 | }
34 | if (!isAuthUser) {
35 | navigate('/');
36 | return null;
37 | }
38 |
39 | return (
40 | <>
41 |
42 | 購入が完了しました
43 |
44 |
45 |
46 | {({ deviceType }) => (
47 |
48 |
49 |
50 |
購入が完了しました
51 |
52 |
53 |
59 | このサイトは架空のサイトであり、商品が発送されることはありません
60 |
61 |
62 |
63 |
64 |
65 |
66 |
こちらの商品もオススメです
67 |
68 |
69 |
70 |
71 |
72 | トップへ戻る
73 |
74 |
75 |
76 |
77 | )}
78 |
79 |
80 | >
81 | );
82 | };
83 |
--------------------------------------------------------------------------------
/src/client/pages/OrderComplete/index.ts:
--------------------------------------------------------------------------------
1 | export { OrderComplete } from './OrderComplete';
2 |
--------------------------------------------------------------------------------
/src/client/pages/ProductDetail/ProductDetail.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const container = () => css`
4 | display: grid;
5 | gap: 40px;
6 | `;
7 |
8 | export const details = () => css`
9 | display: grid;
10 | gap: 20px;
11 | `;
12 |
13 | export const overview = () => css`
14 | padding: 0 16px;
15 | `;
16 |
17 | export const purchase = () => css`
18 | margin-left: auto;
19 | padding: 0 16px;
20 | `;
21 |
22 | export const reviews = () => css`
23 | display: grid;
24 | gap: 20px;
25 | padding: 0 16px;
26 | `;
27 |
28 | export const reviewsHeading = () => css`
29 | font-size: 1.5rem;
30 | font-weight: 700;
31 | `;
32 |
--------------------------------------------------------------------------------
/src/client/pages/ProductDetail/ProductDetail.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { Helmet } from 'react-helmet';
3 | import { useParams } from 'react-router-dom';
4 |
5 | import { Layout } from '../../components/application/Layout';
6 | import { WidthRestriction } from '../../components/foundation/WidthRestriction';
7 | import { ProductMediaListPreviewer } from '../../components/product/ProductMediaListPreviewer';
8 | import { ProductOverview } from '../../components/product/ProductOverview';
9 | import { ProductPurchaseSection } from '../../components/product/ProductPurchaseSeciton';
10 | import { ReviewSection } from '../../components/review/ReviewSection';
11 | import { useActiveOffer } from '../../hooks/useActiveOffer';
12 | import { useAmountInCart } from '../../hooks/useAmountInCart';
13 | import { useAuthUser } from '../../hooks/useAuthUser';
14 | import { useProduct } from '../../hooks/useProduct';
15 | import { useReviews } from '../../hooks/useReviews';
16 | import { useSendReview } from '../../hooks/useSendReview';
17 | import { useUpdateCartItem } from '../../hooks/useUpdateCartItems';
18 | import { useOpenModal } from '../../store/modal';
19 | import { normalizeCartItemCount } from '../../utils/normalize_cart_item';
20 |
21 | import * as styles from './ProductDetail.styles';
22 |
23 | export const ProductDetail: FC = () => {
24 | const { productId } = useParams();
25 |
26 | const { product } = useProduct(Number(productId));
27 | const { reviews } = useReviews(product?.id);
28 | const { isAuthUser } = useAuthUser();
29 | const { sendReview } = useSendReview();
30 | const { updateCartItem } = useUpdateCartItem();
31 | const handleOpenModal = useOpenModal();
32 | const { amountInCart } = useAmountInCart(Number(productId));
33 | const { activeOffer } = useActiveOffer(product);
34 |
35 | const handleSubmitReview = ({ comment }: { comment: string }) => {
36 | sendReview({
37 | variables: {
38 | comment,
39 | productId: Number(productId),
40 | },
41 | });
42 | };
43 |
44 | const handleUpdateItem = (productId: number, amount: number) => {
45 | updateCartItem({
46 | variables: { amount: normalizeCartItemCount(amount), productId },
47 | });
48 | };
49 |
50 | return (
51 | <>
52 | {product && (
53 |
54 | {product.name}
55 |
56 | )}
57 |
58 |
59 |
60 |
61 |
62 |
65 |
66 |
handleOpenModal('SIGN_IN')}
70 | onUpdateCartItem={handleUpdateItem}
71 | product={product}
72 | />
73 |
74 |
75 |
76 |
80 |
81 |
82 |
83 | >
84 | );
85 | };
86 |
--------------------------------------------------------------------------------
/src/client/pages/ProductDetail/index.ts:
--------------------------------------------------------------------------------
1 | export * from './ProductDetail';
2 |
--------------------------------------------------------------------------------
/src/client/pages/Top/Top.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from '@emotion/css';
2 |
3 | export const featureList = () => css`
4 | display: flex;
5 | flex-direction: column;
6 | gap: 24px;
7 | margin-top: 40px;
8 | `;
9 |
10 | export const feature = () => css`
11 | display: flex;
12 | flex-direction: column;
13 | gap: 24px;
14 | `;
15 |
16 | export const featureHeading = () => css`
17 | font-size: 1.5rem;
18 | font-weight: 700;
19 | padding: 0 16px;
20 | `;
21 |
--------------------------------------------------------------------------------
/src/client/pages/Top/Top.tsx:
--------------------------------------------------------------------------------
1 | import type { FC } from 'react';
2 | import { Helmet } from 'react-helmet';
3 |
4 | import { Layout } from '../../components/application/Layout';
5 | import { ProductList } from '../../components/feature/ProductList';
6 | import { ProductHeroImage } from '../../components/product/ProductHeroImage';
7 | import { useFeatures } from '../../hooks/useFeatures';
8 | import { useRecommendation } from '../../hooks/useRecommendation';
9 |
10 | import * as styles from './Top.styles';
11 |
12 | export const Top: FC = () => {
13 | const { recommendation } = useRecommendation();
14 | const { features } = useFeatures();
15 |
16 | if (recommendation === undefined || features === undefined) {
17 | return null;
18 | }
19 |
20 | return (
21 | <>
22 |
23 | 買えるオーガニック
24 |
25 |
26 |
27 |
28 |
29 |
30 | {features.map((featureSection) => {
31 | return (
32 |
33 |
{featureSection.title}
34 |
35 |
36 | );
37 | })}
38 |
39 |
40 |
41 | >
42 | );
43 | };
44 |
--------------------------------------------------------------------------------
/src/client/pages/Top/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Top';
2 |
--------------------------------------------------------------------------------
/src/client/polyfill/install.ts:
--------------------------------------------------------------------------------
1 | import 'core-js';
2 | import 'date-time-format-timezone';
3 | import 'setimmediate';
4 | import './temporal';
5 |
6 | declare global {
7 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
8 | interface Window {
9 | setImmediate: (callback: (...args: T) => void, ...args: T) => number;
10 | clearImmediate: (handle: number) => void;
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/client/polyfill/temporal.ts:
--------------------------------------------------------------------------------
1 | import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
2 |
3 | if (!('Temporal' in window)) {
4 | // @ts-expect-error polyfill
5 | window.Temporal = Temporal;
6 | }
7 |
8 | if (!('toTemporalInstant' in Date.prototype)) {
9 | // @ts-expect-error polyfill
10 | Date.prototype.toTemporalInstant = toTemporalInstant;
11 | }
12 |
13 | declare global {
14 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
15 | interface Window {
16 | Temporal: typeof Temporal;
17 | }
18 |
19 | // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
20 | interface Date {
21 | toTemporalInstant: typeof toTemporalInstant;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/client/store/modal/hooks.ts:
--------------------------------------------------------------------------------
1 | import { useRecoilValue, useSetRecoilState } from 'recoil';
2 |
3 | import type { ModalKey } from './state';
4 | import { modalState } from './state';
5 |
6 | export const useIsOpenModal = (key: ModalKey) => {
7 | const modalKey = useRecoilValue(modalState);
8 |
9 | return modalKey === key;
10 | };
11 |
12 | export const useOpenModal = () => {
13 | const setModal = useSetRecoilState(modalState);
14 |
15 | return (key: ModalKey) => {
16 | setModal(key);
17 | };
18 | };
19 |
20 | export const useCloseModal = () => {
21 | const setModal = useSetRecoilState(modalState);
22 |
23 | return () => {
24 | setModal(undefined);
25 | };
26 | };
27 |
--------------------------------------------------------------------------------
/src/client/store/modal/index.ts:
--------------------------------------------------------------------------------
1 | export * from './hooks';
2 |
--------------------------------------------------------------------------------
/src/client/store/modal/state.ts:
--------------------------------------------------------------------------------
1 | import { atom } from 'recoil';
2 |
3 | export type ModalKey = 'SIGN_UP' | 'SIGN_IN';
4 | export const modalState = atom({ default: undefined, key: 'modal' });
5 |
--------------------------------------------------------------------------------
/src/client/types/zipcode-ja.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'zipcode-ja' {
2 | export default Record<
3 | string,
4 | {
5 | zipcode: string;
6 | zipcodeOld: string;
7 | jisX0402: string;
8 | address: string[];
9 | ruby: string[];
10 | status: number[];
11 | }
12 | >;
13 | }
14 |
--------------------------------------------------------------------------------
/src/client/utils/apollo_client.ts:
--------------------------------------------------------------------------------
1 | import type { HttpOptions } from '@apollo/client';
2 | import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client';
3 |
4 | const syncXhr: HttpOptions['fetch'] = (uri, options) => {
5 | return new Promise((resolve, reject) => {
6 | const method = options?.method;
7 | if (method === undefined) {
8 | return reject();
9 | }
10 |
11 | const body = options?.body;
12 | if (body instanceof ReadableStream) {
13 | return reject();
14 | }
15 |
16 | const request = new XMLHttpRequest();
17 | request.open(method, uri.toString(), false);
18 | request.setRequestHeader('content-type', 'application/json');
19 | request.onload = () => {
20 | if (request.status >= 200 && request.status < 300) {
21 | return resolve(new Response(request.response));
22 | }
23 |
24 | reject();
25 | };
26 | request.onerror = reject;
27 |
28 | request.send(body);
29 | });
30 | };
31 |
32 | const link = new HttpLink({ fetch: syncXhr });
33 |
34 | export const apolloClient = new ApolloClient({
35 | cache: new InMemoryCache(),
36 | connectToDevTools: true,
37 | defaultOptions: {
38 | mutate: {
39 | fetchPolicy: 'network-only',
40 | },
41 | query: {
42 | fetchPolicy: 'network-only',
43 | },
44 | watchQuery: {
45 | fetchPolicy: 'network-only',
46 | },
47 | },
48 | link,
49 | queryDeduplication: false,
50 | uri: '/graphql',
51 | });
52 |
--------------------------------------------------------------------------------
/src/client/utils/get_active_offer.ts:
--------------------------------------------------------------------------------
1 | import type { LimitedTimeOfferFragmentResponse } from '../graphql/fragments';
2 |
3 | export function getActiveOffer(
4 | offers: LimitedTimeOfferFragmentResponse[],
5 | ): LimitedTimeOfferFragmentResponse | undefined {
6 | const activeOffer = offers.find((offer) => {
7 | const now = window.Temporal.Now.instant();
8 | const startDate = window.Temporal.Instant.from(offer.startDate);
9 | const endDate = window.Temporal.Instant.from(offer.endDate);
10 |
11 | return window.Temporal.Instant.compare(startDate, now) < 0 && window.Temporal.Instant.compare(now, endDate) < 0;
12 | });
13 |
14 | return activeOffer;
15 | }
16 |
--------------------------------------------------------------------------------
/src/client/utils/get_media_type.ts:
--------------------------------------------------------------------------------
1 | type MediaType = 'image' | 'video';
2 |
3 | export function getMediaType(filename: string): MediaType {
4 | if (filename.endsWith('.mp4')) {
5 | return 'video';
6 | }
7 |
8 | return 'image';
9 | }
10 |
--------------------------------------------------------------------------------
/src/client/utils/load_fonts.ts:
--------------------------------------------------------------------------------
1 | type FontFaceSource = {
2 | family: string;
3 | source: string;
4 | descripter: FontFaceDescriptors;
5 | };
6 |
7 | const FONT_FACE_SOURCES: FontFaceSource[] = [
8 | {
9 | descripter: {
10 | display: 'block',
11 | style: 'normal',
12 | weight: '700',
13 | },
14 | family: 'Noto Serif JP',
15 | source: "url('/fonts/NotoSerifJP-Bold.otf')",
16 | },
17 | {
18 | descripter: {
19 | display: 'block',
20 | style: 'normal',
21 | weight: '400',
22 | },
23 | family: 'Noto Serif JP',
24 | source: "url('/fonts/NotoSerifJP-Regular.otf')",
25 | },
26 | ];
27 |
28 | export async function loadFonts() {
29 | const fontFaces = FONT_FACE_SOURCES.map(({ descripter, family, source }) => new FontFace(family, source, descripter));
30 | const fonts: FontFace[] = [];
31 |
32 | for (const fontFace of fontFaces) {
33 | const font = await fontFace.load();
34 | fonts.push(font);
35 | }
36 |
37 | for (const font of fontFaces) {
38 | document.fonts.add(font);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/client/utils/normalize_cart_item.ts:
--------------------------------------------------------------------------------
1 | const MAX_LENGTH = 999;
2 |
3 | export const normalizeCartItemCount = (item: number) => Math.min(MAX_LENGTH, Math.max(1, item));
4 |
--------------------------------------------------------------------------------
/src/client/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/model/feature_item.graphql:
--------------------------------------------------------------------------------
1 | type FeatureItem {
2 | id: Int!
3 | product: Product!
4 | }
5 |
--------------------------------------------------------------------------------
/src/model/feature_item.ts:
--------------------------------------------------------------------------------
1 | import { Entity, ManyToOne, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { FeatureSection } from './feature_section';
4 | import { Product } from './product';
5 |
6 | @Entity()
7 | export class FeatureItem {
8 | @PrimaryGeneratedColumn()
9 | id!: number;
10 |
11 | @ManyToOne(() => FeatureSection)
12 | section!: Relation;
13 |
14 | @ManyToOne(() => Product)
15 | product!: Relation;
16 | }
17 |
--------------------------------------------------------------------------------
/src/model/feature_section.graphql:
--------------------------------------------------------------------------------
1 | type FeatureSection {
2 | id: Int!
3 | title: String!
4 | items: [FeatureItem!]!
5 | }
6 |
--------------------------------------------------------------------------------
/src/model/feature_section.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { FeatureItem } from './feature_item';
4 |
5 | @Entity()
6 | export class FeatureSection {
7 | @PrimaryGeneratedColumn()
8 | id!: number;
9 |
10 | @Column()
11 | title!: string;
12 |
13 | @OneToMany(() => FeatureItem, (item) => item.section)
14 | items!: Relation;
15 | }
16 |
--------------------------------------------------------------------------------
/src/model/limited_time_offer.graphql:
--------------------------------------------------------------------------------
1 | type LimitedTimeOffer {
2 | id: Int!
3 | price: Int!
4 | startDate: String!
5 | endDate: String!
6 | }
7 |
--------------------------------------------------------------------------------
/src/model/limited_time_offer.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { Product } from './product';
4 |
5 | @Entity()
6 | export class LimitedTimeOffer {
7 | @PrimaryGeneratedColumn()
8 | id!: number;
9 |
10 | @ManyToOne(() => Product)
11 | product!: Relation;
12 |
13 | @Column()
14 | price!: number;
15 |
16 | @Column()
17 | startDate!: string;
18 |
19 | @Column()
20 | endDate!: string;
21 | }
22 |
--------------------------------------------------------------------------------
/src/model/media_file.graphql:
--------------------------------------------------------------------------------
1 | type MediaFile {
2 | id: Int!
3 | filename: String!
4 | }
5 |
--------------------------------------------------------------------------------
/src/model/media_file.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
2 |
3 | @Entity()
4 | @Unique(['filename'])
5 | export class MediaFile {
6 | @PrimaryGeneratedColumn()
7 | id!: number;
8 |
9 | @Column()
10 | filename!: string;
11 | }
12 |
--------------------------------------------------------------------------------
/src/model/order.graphql:
--------------------------------------------------------------------------------
1 | type Order {
2 | id: Int!
3 | items: [ShoppingCartItem!]!
4 | user: User!
5 | zipCode: String!
6 | address: String!
7 | isOrdered: Boolean!
8 | }
9 |
--------------------------------------------------------------------------------
/src/model/order.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { ShoppingCartItem } from './shopping_cart_item';
4 | import { User } from './user';
5 |
6 | @Entity()
7 | export class Order {
8 | @PrimaryGeneratedColumn()
9 | id!: number;
10 |
11 | @OneToMany(() => ShoppingCartItem, (item) => item.order)
12 | items!: Relation[];
13 |
14 | @ManyToOne(() => User)
15 | user!: Relation;
16 |
17 | @Column()
18 | zipCode!: string;
19 |
20 | @Column()
21 | address!: string;
22 |
23 | @Column()
24 | isOrdered!: boolean;
25 | }
26 |
--------------------------------------------------------------------------------
/src/model/product.graphql:
--------------------------------------------------------------------------------
1 | type Product {
2 | id: Int!
3 | name: String!
4 | price: Int!
5 | description: String!
6 | media: [ProductMedia!]!
7 | offers: [LimitedTimeOffer!]!
8 | reviews: [Review!]!
9 | }
10 |
--------------------------------------------------------------------------------
/src/model/product.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, OneToMany, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { LimitedTimeOffer } from './limited_time_offer';
4 | import { ProductMedia } from './product_media';
5 | import { Review } from './review';
6 |
7 | @Entity()
8 | export class Product {
9 | @PrimaryGeneratedColumn()
10 | id!: number;
11 |
12 | @Column()
13 | name!: string;
14 |
15 | @Column()
16 | price!: number;
17 |
18 | @Column()
19 | description!: string;
20 |
21 | @OneToMany(() => ProductMedia, (media) => media.product)
22 | media!: Relation;
23 |
24 | @OneToMany(() => LimitedTimeOffer, (offer) => offer.product)
25 | offers!: Relation;
26 |
27 | @OneToMany(() => Review, (review) => review.product)
28 | reviews!: Relation;
29 | }
30 |
--------------------------------------------------------------------------------
/src/model/product_media.graphql:
--------------------------------------------------------------------------------
1 | type ProductMedia {
2 | id: Int!
3 | file: MediaFile!
4 | isThumbnail: Boolean!
5 | }
6 |
--------------------------------------------------------------------------------
/src/model/product_media.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { MediaFile } from './media_file';
4 | import { Product } from './product';
5 |
6 | @Entity()
7 | export class ProductMedia {
8 | @PrimaryGeneratedColumn()
9 | id!: number;
10 |
11 | @ManyToOne(() => Product)
12 | product!: Relation;
13 |
14 | @ManyToOne(() => MediaFile)
15 | file!: Relation;
16 |
17 | @Column()
18 | isThumbnail!: boolean;
19 | }
20 |
--------------------------------------------------------------------------------
/src/model/profile.graphql:
--------------------------------------------------------------------------------
1 | type Profile {
2 | id: Int!
3 | name: String!
4 | avatar: MediaFile!
5 | }
6 |
--------------------------------------------------------------------------------
/src/model/profile.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, JoinColumn, ManyToOne, OneToOne, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { MediaFile } from './media_file';
4 | import { User } from './user';
5 |
6 | @Entity()
7 | export class Profile {
8 | @PrimaryGeneratedColumn()
9 | id!: number;
10 |
11 | @OneToOne(() => User)
12 | @JoinColumn()
13 | user!: Relation;
14 |
15 | @Column()
16 | name!: string;
17 |
18 | @ManyToOne(() => MediaFile)
19 | avatar!: Relation;
20 | }
21 |
--------------------------------------------------------------------------------
/src/model/recommendation.graphql:
--------------------------------------------------------------------------------
1 | type Recommendation {
2 | id: Int!
3 | product: Product!
4 | }
5 |
--------------------------------------------------------------------------------
/src/model/recommendation.ts:
--------------------------------------------------------------------------------
1 | import { Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { Product } from './product';
4 |
5 | @Entity()
6 | export class Recommendation {
7 | @PrimaryGeneratedColumn()
8 | id!: number;
9 |
10 | @OneToOne(() => Product)
11 | @JoinColumn()
12 | product!: Relation;
13 | }
14 |
--------------------------------------------------------------------------------
/src/model/review.graphql:
--------------------------------------------------------------------------------
1 | type Review {
2 | id: Int!
3 | user: User!
4 | postedAt: String!
5 | product: Product!
6 | comment: String!
7 | }
8 |
--------------------------------------------------------------------------------
/src/model/review.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { Product } from './product';
4 | import { User } from './user';
5 |
6 | @Entity()
7 | export class Review {
8 | @PrimaryGeneratedColumn()
9 | id!: number;
10 |
11 | @Column()
12 | postedAt!: string;
13 |
14 | @ManyToOne(() => Product, (product) => product.reviews)
15 | product!: Relation;
16 |
17 | @ManyToOne(() => User, (user) => user.reviews)
18 | user!: Relation;
19 |
20 | @Column()
21 | comment!: string;
22 | }
23 |
--------------------------------------------------------------------------------
/src/model/shopping_cart_item.graphql:
--------------------------------------------------------------------------------
1 | type ShoppingCartItem {
2 | id: Int!
3 | product: Product!
4 | amount: Int!
5 | order: Order!
6 | }
7 |
--------------------------------------------------------------------------------
/src/model/shopping_cart_item.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, type Relation } from 'typeorm';
2 |
3 | import { Order } from './order';
4 | import { Product } from './product';
5 |
6 | @Entity()
7 | @Index(['order.id', 'product.id'], { unique: true })
8 | export class ShoppingCartItem {
9 | @PrimaryGeneratedColumn()
10 | id!: number;
11 |
12 | @ManyToOne(() => Product)
13 | product!: Relation;
14 |
15 | @ManyToOne(() => Order)
16 | order!: Relation;
17 |
18 | @Column()
19 | amount!: number;
20 | }
21 |
--------------------------------------------------------------------------------
/src/model/user.graphql:
--------------------------------------------------------------------------------
1 | type User {
2 | id: Int!
3 | email: String!
4 | profile: Profile!
5 | reviews: [Review!]!
6 | orders: [Order!]!
7 | }
8 |
--------------------------------------------------------------------------------
/src/model/user.ts:
--------------------------------------------------------------------------------
1 | import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, type Relation, Unique } from 'typeorm';
2 |
3 | import { Order } from './order';
4 | import { Profile } from './profile';
5 | import { Review } from './review';
6 |
7 | @Entity()
8 | @Unique(['email'])
9 | export class User {
10 | @PrimaryGeneratedColumn()
11 | id!: number;
12 |
13 | @Column()
14 | email!: string;
15 |
16 | @Column()
17 | password!: string;
18 |
19 | @OneToOne(() => Profile, (profile) => profile.user)
20 | profile!: Relation;
21 |
22 | @OneToMany(() => Review, (review) => review.user)
23 | reviews!: Relation;
24 |
25 | @OneToMany(() => Order, (item) => item.user)
26 | orders!: Relation;
27 | }
28 |
--------------------------------------------------------------------------------
/src/server/context.ts:
--------------------------------------------------------------------------------
1 | import type { Session } from 'koa-session';
2 |
3 | export type Context = {
4 | session: Session;
5 | };
6 |
--------------------------------------------------------------------------------
/src/server/data_source.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata';
2 | import { DataSource } from 'typeorm';
3 |
4 | import { FeatureItem } from '../model/feature_item';
5 | import { FeatureSection } from '../model/feature_section';
6 | import { LimitedTimeOffer } from '../model/limited_time_offer';
7 | import { MediaFile } from '../model/media_file';
8 | import { Order } from '../model/order';
9 | import { Product } from '../model/product';
10 | import { ProductMedia } from '../model/product_media';
11 | import { Profile } from '../model/profile';
12 | import { Recommendation } from '../model/recommendation';
13 | import { Review } from '../model/review';
14 | import { ShoppingCartItem } from '../model/shopping_cart_item';
15 | import { User } from '../model/user';
16 |
17 | import { DATABASE_PATH } from './utils/database_paths';
18 |
19 | export const dataSource = new DataSource({
20 | database: DATABASE_PATH,
21 | entities: [
22 | MediaFile,
23 | LimitedTimeOffer,
24 | Order,
25 | ProductMedia,
26 | Product,
27 | Profile,
28 | Recommendation,
29 | Review,
30 | ShoppingCartItem,
31 | User,
32 | FeatureSection,
33 | FeatureItem,
34 | ],
35 | logging: false,
36 | migrations: [],
37 | subscribers: [],
38 | synchronize: true,
39 | type: 'sqlite',
40 | });
41 |
--------------------------------------------------------------------------------
/src/server/graphql/feature_item_resolver.ts:
--------------------------------------------------------------------------------
1 | import type { FeatureItem } from '../../model/feature_item';
2 |
3 | import type { GraphQLModelResolver } from './model_resolver';
4 |
5 | export const featureItemResolver: GraphQLModelResolver = {
6 | product: (parent) => {
7 | return parent.product;
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/src/server/graphql/feature_section_resolver.ts:
--------------------------------------------------------------------------------
1 | import { FeatureItem } from '../../model/feature_item';
2 | import type { FeatureSection } from '../../model/feature_section';
3 | import { dataSource } from '../data_source';
4 |
5 | import type { GraphQLModelResolver } from './model_resolver';
6 |
7 | export const featureSectionResolver: GraphQLModelResolver = {
8 | items: (parent) => {
9 | return dataSource.manager.find(FeatureItem, {
10 | relations: {
11 | product: true,
12 | },
13 | where: {
14 | section: parent,
15 | },
16 | });
17 | },
18 | };
19 |
--------------------------------------------------------------------------------
/src/server/graphql/index.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs/promises';
2 |
3 | import { ApolloServer } from '@apollo/server';
4 | import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default';
5 |
6 | import type { Context } from '../context';
7 | import { rootResolve } from '../utils/root_resolve';
8 |
9 | import { featureItemResolver } from './feature_item_resolver';
10 | import { featureSectionResolver } from './feature_section_resolver';
11 | import { mutationResolver } from './mutation_resolver';
12 | import { orderResolver } from './order_resolver';
13 | import { productMediaResolver } from './product_media_resolver';
14 | import { productResolver } from './product_resolver';
15 | import { profileResolver } from './profile_resolver';
16 | import { queryResolver } from './query_resolver';
17 | import { recommendationResolver } from './recommendation_resolver';
18 | import { reviewResolver } from './review_resolver';
19 | import { shoppingCartItemResolver } from './shopping_cart_item_resolver';
20 | import { userResolver } from './user_resolver';
21 |
22 | export async function initializeApolloServer(): Promise> {
23 | const typeDefs = await Promise.all(
24 | [
25 | rootResolve('./src/model/feature_item.graphql'),
26 | rootResolve('./src/model/feature_section.graphql'),
27 | rootResolve('./src/model/limited_time_offer.graphql'),
28 | rootResolve('./src/model/media_file.graphql'),
29 | rootResolve('./src/model/order.graphql'),
30 | rootResolve('./src/model/product.graphql'),
31 | rootResolve('./src/model/product_media.graphql'),
32 | rootResolve('./src/model/profile.graphql'),
33 | rootResolve('./src/model/recommendation.graphql'),
34 | rootResolve('./src/model/review.graphql'),
35 | rootResolve('./src/model/shopping_cart_item.graphql'),
36 | rootResolve('./src/model/user.graphql'),
37 | rootResolve('./src/server/graphql/mutation.graphql'),
38 | rootResolve('./src/server/graphql/query.graphql'),
39 | ].map((filepath) => fs.readFile(filepath, { encoding: 'utf-8' })),
40 | );
41 |
42 | const server = new ApolloServer({
43 | plugins: [ApolloServerPluginLandingPageLocalDefault({ includeCookies: true })],
44 | resolvers: {
45 | FeatureItem: featureItemResolver,
46 | FeatureSection: featureSectionResolver,
47 | Mutation: mutationResolver,
48 | Order: orderResolver,
49 | Product: productResolver,
50 | ProductMedia: productMediaResolver,
51 | Profile: profileResolver,
52 | Query: queryResolver,
53 | Recommendation: recommendationResolver,
54 | Review: reviewResolver,
55 | ShoppingCartItem: shoppingCartItemResolver,
56 | User: userResolver,
57 | },
58 | typeDefs,
59 | });
60 |
61 | return server;
62 | }
63 |
--------------------------------------------------------------------------------
/src/server/graphql/model_resolver.ts:
--------------------------------------------------------------------------------
1 | export type GraphQLModelResolver = {
2 | [P in keyof T as T[P] extends object ? P : never]?: (parent: T) => T[P] | Promise;
3 | };
4 |
--------------------------------------------------------------------------------
/src/server/graphql/mutation.graphql:
--------------------------------------------------------------------------------
1 | type Mutation {
2 | signin(email: String!, password: String!): Boolean!
3 | signup(email: String!, name: String!, password: String!): Boolean!
4 | signout: Boolean!
5 | sendReview(productId: Int!, comment: String!): Boolean!
6 | updateItemInShoppingCart(productId: Int!, amount: Int!): Boolean!
7 | orderItemsInShoppingCart(zipCode: String!, address: String!): Boolean!
8 | }
9 |
--------------------------------------------------------------------------------
/src/server/graphql/mutation_resolver.ts:
--------------------------------------------------------------------------------
1 | import { Temporal } from '@js-temporal/polyfill';
2 | import * as bcrypt from 'bcrypt';
3 | import type { GraphQLFieldResolver } from 'graphql';
4 |
5 | import { Order } from '../../model/order';
6 | import { Profile } from '../../model/profile';
7 | import { Review } from '../../model/review';
8 | import { ShoppingCartItem } from '../../model/shopping_cart_item';
9 | import { User } from '../../model/user';
10 | import type { Context } from '../context';
11 | import { dataSource } from '../data_source';
12 |
13 | type MutationResolver = {
14 | signin: GraphQLFieldResolver>;
15 | signup: GraphQLFieldResolver>;
16 | sendReview: GraphQLFieldResolver>;
17 | updateItemInShoppingCart: GraphQLFieldResolver<
18 | unknown,
19 | Context,
20 | { productId: number; amount: number },
21 | Promise
22 | >;
23 | orderItemsInShoppingCart: GraphQLFieldResolver<
24 | unknown,
25 | Context,
26 | { zipCode: string; address: string },
27 | Promise
28 | >;
29 | };
30 |
31 | export const mutationResolver: MutationResolver = {
32 | orderItemsInShoppingCart: async (_parent, args, { session }) => {
33 | if (session['userId'] == null) {
34 | throw new Error('Authentication required.');
35 | }
36 |
37 | await dataSource.manager.update(
38 | Order,
39 | {
40 | isOrdered: false,
41 | user: {
42 | id: session['userId'],
43 | },
44 | },
45 | {
46 | address: args.address,
47 | isOrdered: true,
48 | zipCode: args.zipCode,
49 | },
50 | );
51 |
52 | return true;
53 | },
54 | sendReview: async (_parent, args, { session }) => {
55 | if (session['userId'] == null) {
56 | throw new Error('Authentication required.');
57 | }
58 |
59 | const postedAt = Temporal.Now.instant().toString({ timeZone: Temporal.TimeZone.from('UTC') });
60 |
61 | await dataSource.manager.save(
62 | dataSource.manager.create(Review, {
63 | comment: args.comment,
64 | postedAt,
65 | product: {
66 | id: args.productId,
67 | },
68 | user: {
69 | id: session['userId'],
70 | },
71 | }),
72 | );
73 |
74 | return true;
75 | },
76 | signin: async (_parent: unknown, args, { session }) => {
77 | const user = await dataSource.manager.findOneOrFail(User, {
78 | where: {
79 | email: args.email,
80 | },
81 | });
82 |
83 | if ((await bcrypt.compare(args.password, user.password)) !== true) {
84 | throw new Error('Auth error.');
85 | }
86 |
87 | session['userId'] = user.id;
88 | return true;
89 | },
90 | signup: async (_parent, args, { session }) => {
91 | const user = await dataSource.manager.save(
92 | dataSource.manager.create(User, {
93 | email: args.email,
94 | password: await bcrypt.hash(args.password, 10),
95 | }),
96 | );
97 | await dataSource.manager.save(
98 | dataSource.manager.create(Profile, {
99 | avatar: { id: 1 },
100 | name: args.name,
101 | user,
102 | }),
103 | );
104 | session['userId'] = user.id;
105 | return true;
106 | },
107 | updateItemInShoppingCart: async (_parent, args, { session }) => {
108 | if (session['userId'] == null) {
109 | throw new Error('Authentication required.');
110 | }
111 |
112 | const order = await dataSource.manager
113 | .findOneOrFail(Order, {
114 | where: {
115 | isOrdered: false,
116 | user: {
117 | id: session['userId'],
118 | },
119 | },
120 | })
121 | .catch(() => {
122 | return dataSource.manager.save(
123 | dataSource.manager.create(Order, {
124 | address: '',
125 | isOrdered: false,
126 | items: [],
127 | user: {
128 | id: session['userId'],
129 | },
130 | zipCode: '',
131 | }),
132 | );
133 | });
134 |
135 | if (args.amount <= 0) {
136 | await dataSource.manager.delete(ShoppingCartItem, {
137 | order: {
138 | id: order.id,
139 | },
140 | product: {
141 | id: args.productId,
142 | },
143 | });
144 |
145 | return true;
146 | }
147 |
148 | await dataSource.manager.upsert(
149 | ShoppingCartItem,
150 | {
151 | amount: args.amount,
152 | order: {
153 | id: order.id,
154 | },
155 | product: {
156 | id: args.productId,
157 | },
158 | },
159 | {
160 | conflictPaths: ['order.id', 'product.id'],
161 | },
162 | );
163 |
164 | return true;
165 | },
166 | };
167 |
--------------------------------------------------------------------------------
/src/server/graphql/order_resolver.ts:
--------------------------------------------------------------------------------
1 | import type { Order } from '../../model/order';
2 | import { ShoppingCartItem } from '../../model/shopping_cart_item';
3 | import { dataSource } from '../data_source';
4 |
5 | import type { GraphQLModelResolver } from './model_resolver';
6 |
7 | export const orderResolver: GraphQLModelResolver = {
8 | items: async (parent) => {
9 | return dataSource.manager.find(ShoppingCartItem, {
10 | where: {
11 | order: parent,
12 | },
13 | });
14 | },
15 | };
16 |
--------------------------------------------------------------------------------
/src/server/graphql/product_media_resolver.ts:
--------------------------------------------------------------------------------
1 | import { ProductMedia } from '../../model/product_media';
2 | import { dataSource } from '../data_source';
3 |
4 | import type { GraphQLModelResolver } from './model_resolver';
5 |
6 | export const productMediaResolver: GraphQLModelResolver = {
7 | file: async (parent) => {
8 | const productMedia = await dataSource.manager.findOneOrFail(ProductMedia, {
9 | relations: {
10 | file: true,
11 | },
12 | where: { id: parent.id },
13 | });
14 |
15 | return productMedia.file;
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/src/server/graphql/product_resolver.ts:
--------------------------------------------------------------------------------
1 | import { LimitedTimeOffer } from '../../model/limited_time_offer';
2 | import type { Product } from '../../model/product';
3 | import { ProductMedia } from '../../model/product_media';
4 | import { Review } from '../../model/review';
5 | import { dataSource } from '../data_source';
6 |
7 | import type { GraphQLModelResolver } from './model_resolver';
8 |
9 | export const productResolver: GraphQLModelResolver = {
10 | media: (parent) => {
11 | return dataSource.manager.find(ProductMedia, {
12 | where: {
13 | product: parent,
14 | },
15 | });
16 | },
17 | offers: (parent) => {
18 | return dataSource.manager.find(LimitedTimeOffer, {
19 | where: {
20 | product: parent,
21 | },
22 | });
23 | },
24 | reviews: (parent) => {
25 | return dataSource.manager.find(Review, {
26 | where: {
27 | product: parent,
28 | },
29 | });
30 | },
31 | };
32 |
--------------------------------------------------------------------------------
/src/server/graphql/profile_resolver.ts:
--------------------------------------------------------------------------------
1 | import { Profile } from '../../model/profile';
2 | import { dataSource } from '../data_source';
3 |
4 | import type { GraphQLModelResolver } from './model_resolver';
5 |
6 | export const profileResolver: GraphQLModelResolver = {
7 | avatar: async (parent) => {
8 | const profile = await dataSource.manager.findOneOrFail(Profile, {
9 | relations: {
10 | avatar: true,
11 | },
12 | where: { id: parent.id },
13 | });
14 |
15 | return profile.avatar;
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/src/server/graphql/query.graphql:
--------------------------------------------------------------------------------
1 | type Query {
2 | product(id: Int!): Product!
3 | recommendations: [Recommendation!]!
4 | features: [FeatureSection!]!
5 | user(id: Int!): User!
6 | me: User
7 | }
8 |
--------------------------------------------------------------------------------
/src/server/graphql/query_resolver.ts:
--------------------------------------------------------------------------------
1 | import type { Context } from '@apollo/client';
2 | import type { GraphQLFieldResolver } from 'graphql';
3 |
4 | import { FeatureSection } from '../../model/feature_section';
5 | import { Product } from '../../model/product';
6 | import { Recommendation } from '../../model/recommendation';
7 | import { User } from '../../model/user';
8 | import { dataSource } from '../data_source';
9 |
10 | type QueryResolver = {
11 | features: GraphQLFieldResolver>;
12 | me: GraphQLFieldResolver>;
13 | product: GraphQLFieldResolver>;
14 | recommendations: GraphQLFieldResolver>;
15 | user: GraphQLFieldResolver>;
16 | };
17 |
18 | export const queryResolver: QueryResolver = {
19 | features: () => {
20 | return dataSource.manager.find(FeatureSection);
21 | },
22 | me: async (_parent, _args, { session }) => {
23 | if (session['userId'] == null) {
24 | return null;
25 | }
26 | return dataSource.manager.findOneOrFail(User, {
27 | where: { id: session['userId'] },
28 | });
29 | },
30 | product: (_parent, args) => {
31 | return dataSource.manager.findOneOrFail(Product, {
32 | where: { id: args.id },
33 | });
34 | },
35 | recommendations: () => {
36 | return dataSource.manager.find(Recommendation);
37 | },
38 | user: (_parent, args) => {
39 | return dataSource.manager.findOneOrFail(User, {
40 | where: { id: args.id },
41 | });
42 | },
43 | };
44 |
--------------------------------------------------------------------------------
/src/server/graphql/recommendation_resolver.ts:
--------------------------------------------------------------------------------
1 | import { Recommendation } from '../../model/recommendation';
2 | import { dataSource } from '../data_source';
3 |
4 | import type { GraphQLModelResolver } from './model_resolver';
5 |
6 | export const recommendationResolver: GraphQLModelResolver = {
7 | product: async (parent) => {
8 | const recommendation = await dataSource.manager.findOneOrFail(Recommendation, {
9 | relations: {
10 | product: true,
11 | },
12 | where: { id: parent.id },
13 | });
14 |
15 | return recommendation.product;
16 | },
17 | };
18 |
--------------------------------------------------------------------------------
/src/server/graphql/review_resolver.ts:
--------------------------------------------------------------------------------
1 | import { Review } from '../../model/review';
2 | import { dataSource } from '../data_source';
3 |
4 | import type { GraphQLModelResolver } from './model_resolver';
5 |
6 | export const reviewResolver: GraphQLModelResolver = {
7 | product: async (parent) => {
8 | const review = await dataSource.manager.findOneOrFail(Review, {
9 | relations: {
10 | product: true,
11 | },
12 | where: { id: parent.id },
13 | });
14 |
15 | return review.product;
16 | },
17 | user: async (parent) => {
18 | const review = await dataSource.manager.findOneOrFail(Review, {
19 | relations: {
20 | user: true,
21 | },
22 | where: { id: parent.id },
23 | });
24 |
25 | return review.user;
26 | },
27 | };
28 |
--------------------------------------------------------------------------------
/src/server/graphql/shopping_cart_item_resolver.ts:
--------------------------------------------------------------------------------
1 | import { ShoppingCartItem } from '../../model/shopping_cart_item';
2 | import { dataSource } from '../data_source';
3 |
4 | import type { GraphQLModelResolver } from './model_resolver';
5 |
6 | export const shoppingCartItemResolver: GraphQLModelResolver = {
7 | product: async (parent) => {
8 | const item = await dataSource.manager.findOneOrFail(ShoppingCartItem, {
9 | relations: {
10 | product: true,
11 | },
12 | where: {
13 | id: parent.id,
14 | },
15 | });
16 |
17 | return item.product;
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/src/server/graphql/user_resolver.ts:
--------------------------------------------------------------------------------
1 | import { Order } from '../../model/order';
2 | import { Profile } from '../../model/profile';
3 | import { Review } from '../../model/review';
4 | import type { User } from '../../model/user';
5 | import { dataSource } from '../data_source';
6 |
7 | import type { GraphQLModelResolver } from './model_resolver';
8 |
9 | export const userResolver: GraphQLModelResolver = {
10 | orders: (parent) => {
11 | return dataSource.manager.find(Order, {
12 | where: {
13 | user: parent,
14 | },
15 | });
16 | },
17 | profile: (parent) => {
18 | return dataSource.manager.findOneOrFail(Profile, {
19 | where: {
20 | user: parent,
21 | },
22 | });
23 | },
24 | reviews: (parent) => {
25 | return dataSource.manager.find(Review, {
26 | where: {
27 | user: parent,
28 | },
29 | });
30 | },
31 | };
32 |
--------------------------------------------------------------------------------
/src/server/index.ts:
--------------------------------------------------------------------------------
1 | import http from 'node:http';
2 |
3 | import { koaMiddleware } from '@as-integrations/koa';
4 | import gracefulShutdown from 'http-graceful-shutdown';
5 | import Koa from 'koa';
6 | import bodyParser from 'koa-bodyparser';
7 | import logger from 'koa-logger';
8 | import route from 'koa-route';
9 | import send from 'koa-send';
10 | import session from 'koa-session';
11 | import serve from 'koa-static';
12 |
13 | import type { Context } from './context';
14 | import { dataSource } from './data_source';
15 | import { initializeApolloServer } from './graphql';
16 | import { initializeDatabase } from './utils/initialize_database';
17 | import { rootResolve } from './utils/root_resolve';
18 |
19 | const PORT = Number(process.env.PORT ?? 8080);
20 |
21 | async function init(): Promise {
22 | await initializeDatabase();
23 | await dataSource.initialize();
24 |
25 | const app = new Koa();
26 | const httpServer = http.createServer(app.callback());
27 |
28 | app.keys = ['cookie-key'];
29 | app.use(logger());
30 | app.use(bodyParser());
31 | app.use(session({}, app));
32 |
33 | app.use(async (ctx, next) => {
34 | ctx.set('Cache-Control', 'no-store');
35 | await next();
36 | });
37 |
38 | const apolloServer = await initializeApolloServer();
39 | await apolloServer.start();
40 |
41 | app.use(
42 | route.all(
43 | '/graphql',
44 | koaMiddleware(apolloServer, {
45 | context: async ({ ctx }) => {
46 | return { session: ctx.session } as Context;
47 | },
48 | }),
49 | ),
50 | );
51 |
52 | app.use(
53 | route.post('/initialize', async (ctx) => {
54 | await initializeDatabase();
55 | ctx.status = 204;
56 | }),
57 | );
58 |
59 | app.use(serve(rootResolve('dist')));
60 | app.use(serve(rootResolve('public')));
61 |
62 | app.use(async (ctx) => await send(ctx, rootResolve('/dist/index.html')));
63 |
64 | httpServer.listen({ port: PORT }, () => {
65 | console.log(`🚀 Server ready at http://localhost:${PORT}`);
66 | });
67 |
68 | gracefulShutdown(httpServer, {
69 | async onShutdown(signal) {
70 | console.log(`Received signal to terminate: ${signal}`);
71 | await apolloServer.stop();
72 | await dataSource.destroy();
73 | },
74 | });
75 | }
76 |
77 | init().catch((err) => {
78 | console.error(err);
79 | process.exit(1);
80 | });
81 |
--------------------------------------------------------------------------------
/src/server/utils/database_paths.ts:
--------------------------------------------------------------------------------
1 | import { rootResolve } from './root_resolve';
2 |
3 | export const DATABASE_PATH = rootResolve('databases/database.sqlite');
4 | export const DATABASE_SEED_PATH = rootResolve('databases/database.seed.sqlite');
5 |
--------------------------------------------------------------------------------
/src/server/utils/initialize_database.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs/promises';
2 |
3 | import { DATABASE_PATH, DATABASE_SEED_PATH } from './database_paths';
4 |
5 | export const initializeDatabase = async () => {
6 | await fs.copyFile(DATABASE_SEED_PATH, DATABASE_PATH);
7 | };
8 |
--------------------------------------------------------------------------------
/src/server/utils/root_resolve.ts:
--------------------------------------------------------------------------------
1 | import { realpathSync } from 'node:fs';
2 | import { resolve } from 'node:path';
3 |
4 | export const rootDirectory = realpathSync(process.cwd());
5 | export const rootResolve = (...paths: string[]) => resolve(rootDirectory, ...paths);
6 |
--------------------------------------------------------------------------------
/stylelint.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: ['stylelint-config-recommended'],
3 | overrides: [
4 | {
5 | customSyntax: 'postcss-styled-syntax',
6 | files: ['**/*.{js,ts,jsx,tsx}'],
7 | },
8 | ],
9 | plugins: ['stylelint-order'],
10 | rules: {
11 | 'order/properties-alphabetical-order': true,
12 | },
13 | };
14 |
--------------------------------------------------------------------------------
/tools/aozora.ts:
--------------------------------------------------------------------------------
1 | // https://www.aozora.gr.jp/cards/001403/card49969.html
2 | export const kyuri = `今日では温室栽培の向上によって、くだもの、野菜など季節がなくなってしまった。早晩、俳諧歳時記など書き改めねばならなくなりそうだ。とはいっても、やはり旬のものに越したことはない。
3 | あえてきゅうりにはかぎらないが、旬がうまいということは、今も昔も変わらない。
4 | しかし、促成野菜を味なきもののようにいうのは、促成野菜の価値を認識しない批評であって、促成野菜は、いわゆる旬のものにない味わいを持っている。従って、軽々に取り扱うのは考えものである。
5 | 昔は旬のきゅうりという一つのものであったが、今日では促成野菜というものができて、きゅうりもなすも二種類になっているわけである。その他にも一が二になっている促成野菜というものは多種多様に発明されている。
6 | 従って促成と季節と楽しみは二つにふえているわけである。
7 | ところできゅうりはまっすぐなのがよく、ひょうたん形のものはまずい。総じてよいきゅうりは形が平均している。真盛りになると、大きくなっても種がないうちはうまいが、種ができるように成長してしまっては落第である。一般に、温室など利用して作った小さなきゅうり、俗に初物と呼ぶような出たてのきゅうりで、料理屋などで使うのは、小さなのがよい。これは贅沢なシャレた食べ物の場合だが、いいきゅうりの漬けものを賞味しようと思ったら、やはりあとさきの揃ったものを選ぶべきだ。
8 | 漬けものの漬かり加減は非常にむずかしく、気候とぬかみそ漬けの置き場で、漬かり方の速度に非常な差異ができるから、その点、注意深く心がける必要がある。不精して漬け過ぎると、きゅうりはすっぱくなるから、いい加減の時にぬかみそから取り出しておく。取り出しておいても味は急に変わらない。そのまま漬けておいたのでは酸っぱくなってしまう。ちょうどいいと思える時に取り出して、ぬかのついたまま包み、冷たいところに置いておく。そうすれば二、三時間|経ってもうまく食べられる。そのわけは塩が中まで浸潤していかないので味が変わらないからである。
9 | そういったコツは、万人の苦労の集積から生み出されたものであろうが、そのコツを会得し、利用することはよいことである。しかし、なすの場合は出すと、間もなく色が変わるからそういうわけにはいかない。出し置きの利かないなすは、適当な時にぬかみそから出して食べることだ。`;
10 |
11 | // https://www.aozora.gr.jp/cards/001403/card59649.html
12 | export const hakusai = `白菜の煮方などは、一般にあまり吟味したやり方が行われていないと思うので、とりあえずここで扱うことにした。
13 | 白菜というものは、元来中国|青島の産であるが、昔から朝鮮にも多く栽培されていた。これは寒帯にできる野菜であるから、東京辺つまり暖地のものは、品質があまりよくないといえよう。
14 | 白菜の料理は、魚や肉には軽い調節になってよいものだ。白菜は純日本のものではないから、いわゆる日本料理として扱いにくいものの部類に属している。しかし、白菜のスープ煮というものはなかなか気の利いたものである。鶏の骨ばかり(肉のまじらないもの)叩きつぶしてスープを取る。肉のまじったもののスープは、味が俗になってだめだ。このスープは塩で味をつけるのがよろしい。この場合醤油は用いない。醤油では色がついて、白菜の白さを汚してしまうから感じが悪い。あの白い白菜の色を殺してしまうと、よそゆきの料理にはならない。醤油はヤマサ、キッコーマンはもちろんのこと、薄口にしても色がつく。それではお惣菜になってしまう。白菜のスープは純白であること、白菜が白菜そのままの色を保っていることが貴いのである。
15 | 次に味についても、白菜自体の甘味があるから、やはり塩で加減するのがよろしい。煮えたものを器に盛るときは、薬味を用意しておいて添える。
16 | これは日本の新料理であるが、中国料理のやり方も発見されるし、朝鮮料理の気分も味わえるであろう。白菜の切り方や、器の選び方によって、日本料理の感じもする。そして、これは他の料理とも調和する。簡単だから悪い料理ではないといえるだろう。`;
17 |
--------------------------------------------------------------------------------
/tools/get_file_list.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs/promises';
2 | import path from 'node:path';
3 |
4 | export async function getFileList(parent: string): Promise {
5 | const list: string[] = [];
6 | const dirents = await fs.readdir(parent, { withFileTypes: true });
7 |
8 | for (const dirent of dirents) {
9 | if (dirent.isFile()) {
10 | if (dirent.name.startsWith('.')) {
11 | continue;
12 | }
13 |
14 | const file = path.join(parent, dirent.name);
15 | list.push(file);
16 | continue;
17 | }
18 |
19 | if (dirent.isDirectory()) {
20 | const directory = path.join(parent, dirent.name);
21 | const files = await getFileList(directory);
22 | list.push(...files);
23 | continue;
24 | }
25 | }
26 |
27 | return list;
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "allowSyntheticDefaultImports": true,
5 | "emitDecoratorMetadata": true,
6 | "esModuleInterop": true,
7 | "experimentalDecorators": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "isolatedModules": true,
10 | "jsx": "react-jsx",
11 | "lib": ["DOM", "DOM.Iterable", "ESNext"],
12 | "module": "ESNext",
13 | "moduleResolution": "Node",
14 | "noEmit": true,
15 | "resolveJsonModule": true,
16 | "skipLibCheck": true,
17 | "strict": true,
18 | "target": "ESNext",
19 | "useDefineForClassFields": true
20 | },
21 | "include": ["src"],
22 | "references": [{ "path": "./tsconfig.node.json" }],
23 | "ts-node": {
24 | "compilerOptions": {
25 | "module": "CommonJS"
26 | },
27 | "transpileOnly": true
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowSyntheticDefaultImports": true,
4 | "composite": true,
5 | "module": "ESNext",
6 | "moduleResolution": "Node"
7 | },
8 | "files": ["./tools/get_file_list.ts"],
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 |
3 | import react from '@vitejs/plugin-react';
4 | import { defineConfig } from 'vite';
5 | import { ViteEjsPlugin } from 'vite-plugin-ejs';
6 | import topLevelAwait from 'vite-plugin-top-level-await';
7 | import wasm from 'vite-plugin-wasm';
8 |
9 | import { getFileList } from './tools/get_file_list';
10 |
11 | const publicDir = path.resolve(__dirname, './public');
12 | const getPublicFileList = async (targetPath: string) => {
13 | const filePaths = await getFileList(targetPath);
14 | const publicFiles = filePaths
15 | .map((filePath) => path.relative(publicDir, filePath))
16 | .map((filePath) => path.join('/', filePath));
17 |
18 | return publicFiles;
19 | };
20 |
21 | export default defineConfig(async () => {
22 | const videos = await getPublicFileList(path.resolve(publicDir, 'videos'));
23 |
24 | return {
25 | build: {
26 | assetsInlineLimit: 20480,
27 | cssCodeSplit: false,
28 | cssTarget: 'es6',
29 | minify: false,
30 | rollupOptions: {
31 | output: {
32 | experimentalMinChunkSize: 40960,
33 | },
34 | },
35 | target: 'es2015',
36 | },
37 | plugins: [
38 | react(),
39 | wasm(),
40 | topLevelAwait(),
41 | ViteEjsPlugin({
42 | module: '/src/client/index.tsx',
43 | title: '買えるオーガニック',
44 | videos,
45 | }),
46 | ],
47 | };
48 | });
49 |
--------------------------------------------------------------------------------