diff --git a/.yarn/cache/@babel-runtime-npm-7.22.10-2771d0ecab-524d41517e.zip b/.yarn/cache/@babel-runtime-npm-7.22.10-2771d0ecab-524d41517e.zip new file mode 100644 index 000000000..e82a02610 Binary files /dev/null and b/.yarn/cache/@babel-runtime-npm-7.22.10-2771d0ecab-524d41517e.zip differ diff --git a/.yarn/cache/@types-js-cookie-npm-3.0.3-ffeb1814e1-927254ec37.zip b/.yarn/cache/@types-js-cookie-npm-3.0.3-ffeb1814e1-927254ec37.zip new file mode 100644 index 000000000..3a59d1b56 Binary files /dev/null and b/.yarn/cache/@types-js-cookie-npm-3.0.3-ffeb1814e1-927254ec37.zip differ diff --git a/.yarn/cache/date-fns-npm-2.30.0-895c790e0f-f7be015232.zip b/.yarn/cache/date-fns-npm-2.30.0-895c790e0f-f7be015232.zip new file mode 100644 index 000000000..f51ffd3ec Binary files /dev/null and b/.yarn/cache/date-fns-npm-2.30.0-895c790e0f-f7be015232.zip differ diff --git a/.yarn/cache/js-cookie-npm-3.0.5-8fc8fcc9b4-2dbd2809c6.zip b/.yarn/cache/js-cookie-npm-3.0.5-8fc8fcc9b4-2dbd2809c6.zip new file mode 100644 index 000000000..a8eacc4b1 Binary files /dev/null and b/.yarn/cache/js-cookie-npm-3.0.5-8fc8fcc9b4-2dbd2809c6.zip differ diff --git a/.yarn/cache/regenerator-runtime-npm-0.14.0-e060897cf7-1c977ad82a.zip b/.yarn/cache/regenerator-runtime-npm-0.14.0-e060897cf7-1c977ad82a.zip new file mode 100644 index 000000000..743dca6a4 Binary files /dev/null and b/.yarn/cache/regenerator-runtime-npm-0.14.0-e060897cf7-1c977ad82a.zip differ diff --git a/app/hooks/use-age-confirmation.tsx b/app/hooks/use-age-confirmation.tsx new file mode 100644 index 000000000..046dc06dd --- /dev/null +++ b/app/hooks/use-age-confirmation.tsx @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; + +import Cookies from 'js-cookie'; + +const COOKIE_NAME = 'age_confirm'; + +export const useAgeConfirmation = () => { + const [ageConfirmed, setAgeConfirmed] = useState(true); + + useEffect(() => { + if (!Cookies.get(COOKIE_NAME)) { + setAgeConfirmed(false); + } + }, []); + + const confirmAge = () => { + setAgeConfirmed(true); + Cookies.set(COOKIE_NAME, 'confirmed', { expires: 365 }); + }; + + return { + ageConfirmed, + onAgeConfirmed: confirmAge + }; +}; diff --git a/components/cart/age-gate-confirm-before-checkout.tsx b/components/cart/age-gate-confirm-before-checkout.tsx new file mode 100644 index 000000000..f055e3201 --- /dev/null +++ b/components/cart/age-gate-confirm-before-checkout.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useAgeConfirmation } from 'app/hooks/use-age-confirmation'; +import AgeGateForm from 'components/product/age-gate-form'; +import Link from 'next/link'; +import { FC, ReactNode, useState } from 'react'; + +type AgeConfirmBeforeCheckoutProps = { + children: ReactNode[] | ReactNode | string; + checkoutUrl: string; +}; + +const AgeConfirmBeforeCheckout: FC = ({ children, checkoutUrl }) => { + const [isConfirming, setIsConfirming] = useState(false); + const { ageConfirmed } = useAgeConfirmation(); + + return ageConfirmed ? ( + <> + + {children} + + + ) : ( + <> + + {!!isConfirming && ( + setIsConfirming(false)} checkoutUrl={checkoutUrl} /> + )} + + ); +}; + +export default AgeConfirmBeforeCheckout; diff --git a/components/cart/modal.tsx b/components/cart/modal.tsx index dc9ebb14f..43e12bd3e 100644 --- a/components/cart/modal.tsx +++ b/components/cart/modal.tsx @@ -6,9 +6,11 @@ import Price from 'components/price'; import { DEFAULT_OPTION } from 'lib/constants'; import type { Cart } from 'lib/shopify/types'; import { createUrl } from 'lib/utils'; +import { useTranslations } from 'next-intl'; import Image from 'next/image'; import Link from 'next/link'; import { Fragment, useEffect, useRef, useState } from 'react'; +import AgeConfirmBeforeCheckout from './age-gate-confirm-before-checkout'; import CloseCart from './close-cart'; import DeleteItemButton from './delete-item-button'; import EditItemQuantityButton from './edit-item-quantity-button'; @@ -19,6 +21,7 @@ type MerchandiseSearchParams = { }; export default function CartModal({ cart }: { cart: Cart | undefined }) { + const t = useTranslations('Index'); const [isOpen, setIsOpen] = useState(false); const quantityRef = useRef(cart?.totalQuantity); const openCart = () => setIsOpen(true); @@ -169,12 +172,9 @@ export default function CartModal({ cart }: { cart: Cart | undefined }) { /> - - Proceed to Checkout - + + {t('cart.proceed')} + )} diff --git a/components/product/age-gate-form.tsx b/components/product/age-gate-form.tsx new file mode 100644 index 000000000..b69e60783 --- /dev/null +++ b/components/product/age-gate-form.tsx @@ -0,0 +1,212 @@ +'use client'; + +/* This example requires Tailwind CSS v2.0+ */ +import { FC, Fragment, useEffect, useRef, useState, useTransition } from 'react'; + +import { Dialog, Transition } from '@headlessui/react'; +import { CheckIcon } from '@heroicons/react/24/outline'; +import { useAgeConfirmation } from 'app/hooks/use-age-confirmation'; +import clsx from 'clsx'; +import { isBefore, isValid, parse } from 'date-fns'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; + +type AgeGateFormProps = { + checkoutUrl: string; + didCancel?: () => void; +}; + +const AgeGateForm: FC = ({ checkoutUrl, didCancel }) => { + const t = useTranslations('Index'); + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [hasValidDate, setHasValidDate] = useState(false); + const [month, setMonth] = useState(); + const [day, setDay] = useState(); + const [year, setYear] = useState(); + + const { onAgeConfirmed } = useAgeConfirmation(); + + const yearFieldRef = useRef(null); + + const minAge = 20; + const maxAge = 130; + + const save = () => { + if (hasValidDate) { + onAgeConfirmed(); + startTransition(() => { + router.push(checkoutUrl); + }); + } + }; + + const cancel = () => { + if (didCancel) { + didCancel(); + } + }; + + useEffect(() => { + const now = new Date(); + const thresholdDate = new Date(now.getFullYear() - minAge, now.getMonth(), now.getDate()); + const minDate = new Date(now.getFullYear() - maxAge, now.getMonth(), now.getDate()); + if (month && day && year) { + const date = parse(`${month}-${day}-${year}`, 'MM-dd-yyyy', new Date()); + const oldEnough = isBefore(date, thresholdDate); + const tooOld = isBefore(date, minDate); + setHasValidDate(isValid(date) && oldEnough && !tooOld); + } else { + setHasValidDate(false); + } + }, [month, day, year]); + + return ( + <> + + {}} + > +
+ + + + + {/* This element is to trick the browser into centering the modal contents. */} + + +
+
+
+
+
+ + {t('age-gate.title')} + +
+

+ {t('age-gate.description')} +

+
+
+
+
+
{t('age-gate.birthdate')}
+
+
+
+
+ setYear(parseInt(e?.target?.value))} + /> +
{t('age-gate.year')}
+
+ +
+ setMonth(parseInt(e?.target?.value))} + /> +
{t('age-gate.month')}
+
+
+ setDay(parseInt(e?.target?.value))} + /> +
{t('age-gate.day')}
+
+
+
+
+ + +
+
+
+
+
+
+ + ); +}; + +export default AgeGateForm; diff --git a/messages/en.json b/messages/en.json index 756a98e9f..b3e60c912 100644 --- a/messages/en.json +++ b/messages/en.json @@ -69,7 +69,38 @@ }, "cart": { "add": "Add to cart", - "out-of-stock": "Out of stock" + "out-of-stock": "Out of stock", + "title": "Shopping Bag", + "subtitle": "Review your Order", + "empty": "Your shopping bag is empty", + "declinedCard": "We couldn't process the purchase. Please check your card information and try again.", + "thankYou": "Thank you for your order.", + "subtotal": "Subtotal", + "taxes": "Taxes", + "taxCalculation": "Calculated at checkout", + "shipping": "Shipping", + "shippingCalculation": "Calculated at checkout", + "total": "Total", + "proceed": "Proceed to Checkout", + "continue": "Continue Shopping", + "note": "Notes", + "notePlaceholder": "Enter any notes you would like to include with your order", + "addNote": "Add a note to your order", + "editNote": "Edit note", + "hideNote": "Hide", + "saving": "Saving...", + "addFeaturedProduct": "+ Add" + }, + "age-gate": { + "title": "Confirm Your Age", + "description": "In order to shop on this site, please confirm that you are of legal age to purchase alcohol under the laws that apply to you.", + "confirm": "Confirm", + "confirming": "Confirming", + "deny": "No", + "birthdate": "Your date of birth", + "year": "Year", + "month": "Month", + "day": "Day" } } } diff --git a/messages/ja.json b/messages/ja.json index 36cdda55d..17d768d06 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -69,7 +69,38 @@ }, "cart": { "add": "カートに入れる", - "out-of-stock": "品切れ中" + "out-of-stock": "品切れ中", + "title": "ショッピングカード", + "subtitle": "ご注文内容の確認", + "empty": "買い物袋が空っぽ", + "declinedCard": "購入手続きができませんでした。カード情報をご確認の上、再度お試しください。", + "thankYou": "この度はご注文ありがとうございました。", + "subtotal": "サブトータル", + "taxes": "税金", + "taxCalculation": "チェックアウト時に計算されます", + "shipping": "配送", + "shippingCalculation": "チェックアウト時に計算されます", + "total": "合計", + "proceed": "チェックアウトに進む", + "continue": "ショッピングを続ける", + "note": "メモ", + "notePlaceholder": "注文時に添えるメモを入力してください", + "addNote": "注文にメモを追加します", + "editNote": "メモを編集します", + "hideNote": "隠れる", + "saving": "セービング...", + "addFeaturedProduct": "+ 入れる" + }, + "age-gate": { + "title": "年齢確認", + "description": "当サイトでのご購入には、お客さまがアルコール飲料を購入できる年齢であることの確認が必要となります。", + "confirm": "はい", + "confirming": "確認中", + "deny": "いいえ", + "birthdate": "生年月日を入力してください。", + "year": "年", + "month": "月", + "day": "日" } } } diff --git a/package.json b/package.json index e45fd1659..ef162571a 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,10 @@ "@heroicons/react": "^2.0.18", "@thgh/next-gtm": "^0.1.4", "clsx": "^2.0.0", + "date-fns": "^2.30.0", "eslint-plugin-tailwindcss": "^3.13.0", "eslint-plugin-unused-imports": "^3.0.0", + "js-cookie": "^3.0.5", "negotiator": "^0.6.3", "next": "latest", "next-intl": "latest", @@ -40,6 +42,7 @@ "devDependencies": { "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/typography": "^0.5.9", + "@types/js-cookie": "^3.0.3", "@types/negotiator": "^0.6.1", "@types/node": "20.4.4", "@types/react": "18.2.16", diff --git a/yarn.lock b/yarn.lock index f45f6c439..36b583ccd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -55,6 +55,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.21.0": + version: 7.22.10 + resolution: "@babel/runtime@npm:7.22.10" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: 524d41517e68953dbc73a4f3616b8475e5813f64e28ba89ff5fca2c044d535c2ea1a3f310df1e5bb06162e1f0b401b5c4af73fe6e2519ca2450d9d8c44cf268d + languageName: node + linkType: hard + "@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" @@ -476,6 +485,13 @@ __metadata: languageName: node linkType: hard +"@types/js-cookie@npm:^3.0.3": + version: 3.0.3 + resolution: "@types/js-cookie@npm:3.0.3" + checksum: 927254ec37ce4fbe4d9d54f53a446b4351259799d9933db5808ddb7c430396aa2496bdd0a4e47e1b56048ffbec98645cbd4daa9e3ed9a6fff55e25eb640fcb15 + languageName: node + linkType: hard + "@types/json5@npm:^0.0.29": version: 0.0.29 resolution: "@types/json5@npm:0.0.29" @@ -1272,18 +1288,21 @@ __metadata: "@tailwindcss/container-queries": ^0.1.1 "@tailwindcss/typography": ^0.5.9 "@thgh/next-gtm": ^0.1.4 + "@types/js-cookie": ^3.0.3 "@types/negotiator": ^0.6.1 "@types/node": 20.4.4 "@types/react": 18.2.16 "@types/react-dom": 18.2.7 autoprefixer: ^10.4.14 clsx: ^2.0.0 + date-fns: ^2.30.0 eslint: ^8.45.0 eslint-config-next: latest eslint-config-prettier: ^8.8.0 eslint-plugin-tailwindcss: ^3.13.0 eslint-plugin-unicorn: ^48.0.0 eslint-plugin-unused-imports: ^3.0.0 + js-cookie: ^3.0.5 lint-staged: ^13.2.3 negotiator: ^0.6.3 next: latest @@ -1348,6 +1367,15 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^2.30.0": + version: 2.30.0 + resolution: "date-fns@npm:2.30.0" + dependencies: + "@babel/runtime": ^7.21.0 + checksum: f7be01523282e9bb06c0cd2693d34f245247a29098527d4420628966a2d9aad154bd0e90a6b1cf66d37adcb769cd108cf8a7bd49d76db0fb119af5cdd13644f4 + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" @@ -2919,6 +2947,13 @@ __metadata: languageName: node linkType: hard +"js-cookie@npm:^3.0.5": + version: 3.0.5 + resolution: "js-cookie@npm:3.0.5" + checksum: 2dbd2809c6180fbcf060c6957cb82dbb47edae0ead6bd71cbeedf448aa6b6923115003b995f7d3e3077bfe2cb76295ea6b584eb7196cca8ba0a09f389f64967a + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -4212,6 +4247,13 @@ __metadata: languageName: node linkType: hard +"regenerator-runtime@npm:^0.14.0": + version: 0.14.0 + resolution: "regenerator-runtime@npm:0.14.0" + checksum: 1c977ad82a82a4412e4f639d65d22be376d3ebdd30da2c003eeafdaaacd03fc00c2320f18120007ee700900979284fc78a9f00da7fb593f6e6eeebc673fba9a3 + languageName: node + linkType: hard + "regexp-tree@npm:^0.1.27": version: 0.1.27 resolution: "regexp-tree@npm:0.1.27"