Compare commits

..

4 Commits

Author SHA1 Message Date
7521e98ff3 Merge pull request 'feat/storefront' (#3) from feat/storefront into staging
All checks were successful
CI / Lint, Typecheck & Test (push) Successful in 1m39s
Deploy — Staging / Detect changed apps (push) Successful in 15s
Deploy — Staging / Lint, Typecheck & Test (push) Successful in 1m41s
Deploy — Staging / Build & push — storefront (push) Successful in 2m50s
Deploy — Staging / Build & push — admin (push) Successful in 2m34s
Deploy — Staging / Deploy to staging VPS (push) Successful in 21s
Reviewed-on: http://72.61.144.167:3000/admin/the-pet-loft/pulls/3
2026-03-13 19:02:43 +00:00
56d7a653eb chore(gitea): add PR template and instructions for merging feat/storefront into staging
All checks were successful
CI / Lint, Typecheck & Test (push) Successful in 1m49s
Made-with: Cursor
2026-03-13 21:54:20 +03:00
0cb2c00f43 fix(ci): update branch pattern in CI workflow
All checks were successful
CI / Lint, Typecheck & Test (push) Successful in 2m28s
Changed the branch pattern in the CI workflow from a specific feature branch to a wildcard pattern ("**") for broader applicability. This adjustment is part of ongoing testing and refinement of the CI process.
2026-03-13 21:44:18 +03:00
c8f5d8d096 feat(storefront): update FAQ and legal documentation
- Added new FAQ sections for account security, ordering and checkout, returns, shipping, and contact information.
- Introduced legal documents including privacy policy, terms of service, data protection, and general terms and conditions.
- Updated package dependencies to include gray-matter and remark-gfm for enhanced markdown support.
2026-03-13 21:39:25 +03:00
55 changed files with 2312 additions and 262 deletions

View File

@@ -0,0 +1,24 @@
# Open Pull Request: feat/storefront → staging
## 1. Push your branch (if needed)
```bash
git push gitea feat/storefront
```
## 2. Create the pull request in Gitea
Open this URL in your browser (replace `GITEA_BASE_URL` with your Gitea UI base, e.g. `https://72.61.144.167:3000` or your actual Gitea domain):
```
GITEA_BASE_URL/admin/the-pet-loft/compare/staging...feat/storefront
```
Or in Gitea UI:
1. Go to **admin/the-pet-loft**
2. Click **Pull requests****New pull request**
3. Set **Base** = `staging`, **Head** = `feat/storefront`
4. Use the pre-filled description from `.gitea/PULL_REQUEST_TEMPLATE.md` if needed
5. Create the pull request
## 3. After CI passes
Merge the PR into `staging` from the Gitea PR page.

View File

@@ -0,0 +1,14 @@
## Summary
Merge `feat/storefront` into `staging` to bring storefront and support/legal updates onto the staging branch.
## Changes (feat/storefront → staging)
- **Returns & refunds**: Support returns page, FAQ, and General Terms updated so customer bears return postage cost (deducted from refund).
- **Storefront**: Returns policy page content aligned with legal (14-day withdrawal, return address, exclusions).
- **CI**: Branch pattern in workflow updated.
## Checklist
- [ ] CI (lint, typecheck, test) passes on `feat/storefront`
- [ ] No merge conflicts with `staging`
## How to merge
After approval, merge this PR into `staging` (merge commit or squash as per your workflow).

View File

@@ -3,7 +3,7 @@ name: CI
on: on:
push: push:
branches: branches:
- feat #"**" # TODO: change to "**" after testing - "**" # TODO: change to "**" after testing
jobs: jobs:
ci: ci:

View File

@@ -0,0 +1,11 @@
---
title: Account & security
subtitle: Sign-in, password reset, and account access.
order: 4
---
### How do I reset my password?
If you signed up with email and password, use the **Forgot password** link on the sign-in page. You will receive an email with instructions to reset your password. If you signed up with Google or another provider, sign in using that same option; there is no separate password to reset.
### Where can I see my order history?
Sign in and go to [Order History](/account/orders). From there you can view your orders, see tracking information, and request returns where applicable.

View File

@@ -0,0 +1,14 @@
---
title: Contact
subtitle: How to get in touch and how we handle complaints.
order: 5
---
### How can I contact you?
You can reach us by filling in our [Contact Us](/support/contact-us) form or by emailing **service@thepetloft.co.uk**. We will respond as soon as we can.
### What topic should I choose on the contact form?
Choose **Orders** for questions about delivery, returns, or a specific order. Choose **Support** for general questions about our products or services. Use **Products** for product-related inquiries and **Other** for anything else.
### How do I make a complaint?
You can make a complaint by emailing **service@thepetloft.co.uk** or by using our [Contact Us](/support/contact-us) form. Please include your order number if your complaint relates to an order. We will acknowledge your complaint and work to resolve it.

View File

@@ -0,0 +1,17 @@
---
title: Ordering & checkout
subtitle: Questions about placing orders and payment.
order: 1
---
### How do I place an order?
Browse our shop, add items to your cart, and go to checkout. You must sign in or create an account to complete your order. Follow the steps to enter your shipping address, review your order, and pay securely.
### Do I need an account to checkout?
Yes. You can browse and add items to your cart as a guest, but you must sign in or create an account to complete checkout.
### What payment methods do you accept?
We accept major cards and digital wallets including Visa, Mastercard, Discover, Apple Pay, Google Pay, Link, Revolut Pay, Billie, Cartes, and Klarna. The options available at checkout may vary.
### Is my payment information secure?
Yes. We process payments through Stripe. We do not store your full card number on our servers. Your payment details are handled securely by our payment provider.

View File

@@ -0,0 +1,26 @@
---
title: Returns
subtitle: Returns, refunds, and non-returnable items.
order: 3
---
### How do I return an item?
You have the right to withdraw from your order within **14 days** of receiving the goods. To start a return, inform us of your decision by email to [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk) or by post to The Pet Loft UK, Customer Services, 39a Walton Road, Woking, GU21 5DL. You can use the model withdrawal form on our [Returns & Refunds Policy](/support/returns) page, but it is not required. We will confirm next steps. You will incur the cost of returning the product; this amount will be deducted from your refund. Send the item back to **Fanaaka Ltd**, 39a Walton Road, Woking, GU21 5DL within 14 days of telling us you are withdrawing. For full details, see our [Returns & Refunds Policy](/support/returns).
### What items cannot be returned?
The right of withdrawal does not apply to: goods made to your order or clearly tailored to your personal requirements; goods that may perish quickly or whose use-by date would expire rapidly; goods not suitable for return for reasons of health or hygiene if their seal has been broken after delivery; and goods that were, after delivery, inseparably mixed with other goods. If you are unsure whether your item can be returned, please [contact us](/support/contact-us) before sending it back.
### My item is wrong or damaged. What do I do?
Please [contact us](/support/contact-us) as soon as possible with your order number and a description of the issue (and photos if helpful). We will arrange for the item to be returned and will cover return postage where the error or damage is on our side. For goods damaged in transit, notifying us promptly also helps us claim from the carrier.
### When will I receive my refund?
After we receive your returned item (or evidence that you have sent it back), we will reimburse you without undue delay and in any event within **14 days**. The cost of return postage will be deducted from your refund amount. We use the same payment method you used for the order. Refunds may take a few extra business days to show on your bank or card statement.
### Where do I send my return?
Send the goods to **Fanaaka Ltd**, 39a Walton Road, Woking, GU21 5DL. Please send them back within 14 days of informing us of your withdrawal. The deadline is met if you dispatch the goods before the 14-day period has expired.
### Do I have to pay for return postage?
Yes. The customer incurs the cost of returning the product, and this amount will be deducted from the refund. You are also responsible for any loss in value caused by handling the item beyond what is needed to check its nature, characteristics, and functioning.
### How long do I have to request a return?
You have **14 days** from the day you (or someone you nominate, other than the carrier) receive the goods to inform us of your decision to withdraw. Your communication must reach us before the 14-day period has expired. You must then send the goods back within 14 days of telling us you are withdrawing.

View File

@@ -0,0 +1,17 @@
---
title: Shipping
subtitle: Delivery times, costs, and tracking.
order: 2
---
### Do you ship to my country?
We currently ship only within the United Kingdom. We do not offer international shipping.
### How much is delivery?
Free standard delivery is applied automatically at checkout on all orders over £40. Orders under £40 may incur a delivery charge, which will be shown before you pay.
### How long does delivery take?
Standard delivery typically takes 35 working days for UK mainland. We partner with trusted carriers including DPD and Evri. Delivery times are estimates and may vary during peak periods.
### How can I track my order?
Tracking information is available on your order detail page when signed in. Go to your [Order History](/account/orders), select the order, and view the tracking details there.

View File

View File

@@ -0,0 +1,25 @@
---
title: Data Protection
description: The Pet Loft data protection and GDPR information.
lastUpdated: March 2025
---
## Data controller
The Pet Loft is the data controller for the personal data we process in connection with our website and orders.
## Legal basis for processing
We process your data where necessary to perform our contract with you (e.g. fulfilling orders), where required by law, and where we have a legitimate interest (e.g. improving our services), in line with applicable data protection law.
## Retention
We retain your data only for as long as necessary to fulfil the purposes set out in our Privacy Policy and to comply with legal obligations.
## International transfers
If we transfer your data outside the UK or EEA, we ensure appropriate safeguards are in place as required by law.
## Complaints
You have the right to lodge a complaint with a supervisory authority if you believe our processing of your data infringes applicable law.

View File

@@ -0,0 +1,286 @@
# General Terms and Conditions
**The Pet Loft UK** | A division of Fanaaka Ltd, 39a Walton Road, GU21, United Kingdom
**Website:** [www.thepetloft.co.uk](http://www.thepetloft.co.uk)
**Last updated:** 02 February 2026
---
## 1. Scope
These General Terms and Conditions of Business apply to all orders and deliveries between **The Pet Loft UK**, a division of Fanaaka Ltd, 39a Walton Road, GU21, United Kingdom (hereinafter: **"Pet Loft"**) and its customers via the online shop [www.thepetloft.co.uk](http://www.thepetloft.co.uk).
---
## 2. Order Process, Entry into a Contract, Quantity Limitation & Commercial Resale
### 2.1 Order Process
The Pet Loft offers its customers a comprehensive range for all matters concerning domestic pets. By clicking on the products or product descriptions, the customer navigates to the product details — e.g. details regarding the product design, size, colour, or flavour. The product is placed in the virtual shopping basket/cart by entering the requested quantity and clicking on the shopping basket icon.
By clicking on the **"Shopping Cart"** button, displayed in the top right-hand corner of the online shop, the customer navigates to an overview page and can at any time check the goods in the virtual shopping basket and, where necessary, make changes.
If a customer does not wish to purchase additional goods, they can continue via the **"To Order/Checkout"** button. Registered customers can enter their usernames and passwords here to automatically use their saved information for the order. Alternatively, the customer can register as a new customer and set up a customer account, or continue the purchase without setting up a customer account. In such a case, the customer must enter their address and invoice information on the following page.
By further clicking on the **"Continue"** button, the customer reaches the penultimate order stage — **"Overview"**. The customer has access to an overview of the order here, with details of the price (including the statutory VAT) and details of the delivery service and costs.
The order is placed by clicking on the **"Buy"** button. This constitutes a binding offer.
The Pet Loft does not charge any fees for the use of remote communication systems, but the customer may incur the usual costs associated with the use of these services from third parties (e.g. mobile operator, internet provider).
### 2.2 Entry into a Contract
**a.** The goods offered in the shop are sold exclusively to non-commercial individuals — i.e. only to consumers who conclude the legal transaction for purposes that can be attributed neither to their commercial nor to their independent professional activity. The offers appearing on our website [www.thepetloft.co.uk](http://www.thepetloft.co.uk) are therefore not aimed at businesses. Businesses are natural or legal persons, or partnerships with legal capacity, who, when concluding a legal transaction, act in the exercise of their commercial or self-employed professional activity.
**b.** The images of the range in the online shop are intended as an illustration and do not constitute binding offers for sale. By completing the order process by clicking on the **"Buy"** button, the customer makes a binding offer to enter into a purchase contract. The customer thereupon receives an automated confirmation of receipt of order by email (**order confirmation**). This order confirmation does not constitute acceptance of the offer. The contract with The Pet Loft is only concluded when The Pet Loft sends the ordered product to the customer and the shipping is confirmed by email (**shipping confirmation**).
**c.** Notwithstanding clause 2.2(b), if the customer chooses to pay in advance, a contract is already concluded when The Pet Loft sends the payment information. This payment information will be sent to the customer within **24 hours** of submitting the order. The order confirmation does not constitute payment information. In the case of payment in advance, the invoice amount shall be due upon receipt of the payment information and shall be paid within **7 days** of receipt by bank transfer to one of the accounts listed under clause 8.1(b). Receipt of the invoice amount on our account is decisive for compliance with the payment deadline. Should no payment be recorded on one of the accounts specified under clause 8.1(b) after 7 days, the customer's order will be automatically cancelled.
**d.** The contract language is **English**.
### 2.3 Contract Text
The text of the contract will be stored by us until the order has been processed in full, after which it will be archived in accordance with tax and commercial law. Upon receipt of the order by The Pet Loft, the purchaser will receive a separate confirmation email containing the essential contents of the contract, including the General Terms and Conditions valid at the time of the contract. If you lose your documents relating to your orders, please contact us — we will be happy to send you a copy of your order data.
### 2.4 Quantity Limitation, Maximum Order Value & Commercial Resale
The offered goods are sold in customary domestic quantities only, and only to persons of full age. The **commercial resale** of goods is not permitted. The Pet Loft reserves the right not to accept contractual offers that appear to be made for the purpose of the commercial resale of goods.
---
## 3. Prices and Shipping Charges
All prices include **statutory VAT** and other price components, and are exclusive of any shipping costs.
We deliver within the **United Kingdom only**.
If you order products from The Pet Loft for delivery outside the EU, you may be subject to import duties and taxes, which will be levied once the package reaches the specified destination. Any additional charges for customs clearance must be borne by you. We have no control over these charges. Customs regulations vary widely from country to country, so you should contact your local customs office for more information.
---
## 4. Delivery
Deliveries are only made within the **United Kingdom**.
Unless stated otherwise in the offer or product details, delivery takes place within **1 to 3 business days**. The deadline for delivery begins on the day after the contract is concluded, except for payment in advance — in that case, the deadline begins on the day after the payment order has been issued. If the last day of the deadline falls on a Saturday, Sunday, or a public holiday recognised by the state at the place of delivery, the deadline is automatically extended to the next working day.
In the event that some of the ordered products are not in stock, The Pet Loft shall be entitled to provide **partial deliveries** at its own cost, provided this is acceptable to the customer.
In the event that The Pet Loft is unable to deliver the ordered product because it is not supplied by its own suppliers — and without culpability on the part of The Pet Loft — The Pet Loft may withdraw from the contract. In such a case, The Pet Loft shall inform the customer without delay and propose a comparable product. If a comparable product is not available, or if the customer does not wish to have that product delivered, The Pet Loft shall, without delay, reimburse any payments made by the customer. Deliveries are free of customs duties within the EU. In the case of delivery to countries outside the EU, customs duties, taxes, and other applicable levies shall be borne by the customer.
In the event that supplied products are damaged in transit, The Pet Loft customer service is to be contacted as soon as possible. This enables The Pet Loft to lodge a complaint with the carrier or transport insurer regarding the damage. Failure by the customer to provide notification of transport damage shall not affect the customer's statutory guarantee rights in any way.
In order to fulfil customer orders, The Pet Loft needs to pass on the customer's email address and, if available, a contact phone number to the delivery company authorised to deliver the goods. This forms part of the contract with The Pet Loft. The customer does not have the right to object to this. For further information, please see our **Data Protection** page.
---
## 5. Retention of Title
The goods shall remain the **property of The Pet Loft** until payment in full. Prior to the passing of ownership, pledging, ownership transfer by way of security, processing, or redesigning are not permitted without approval from The Pet Loft.
---
## 6. Right of Withdrawal
Consumers have a statutory right of withdrawal when concluding a distance selling contract. The Pet Loft provides the following information in accordance with the statutory model. A consumer is any natural person who enters into a legal transaction for purposes that are predominantly neither commercial nor self-employed. If customers have any further questions about cancellations, they can contact The Pet Loft customer service.
### Instructions on Withdrawal
#### Right of Withdrawal
You have the right to **withdraw from this contract within 14 days** without giving any reason.
The withdrawal period will expire after **14 days** from the day on which you acquire, or a third party (other than the carrier and as indicated by you), acquires physical possession of the goods.
To exercise the right of withdrawal, you must inform us at:
> **The Pet Loft UK**, Customer Services, 39a Walton Road, Woking, GU21 5DL
> Email: [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk)
of your decision to withdraw from this contract by an unequivocal statement (e.g. a letter sent by post or email). You may use the attached model withdrawal form, but it is not obligatory.
To meet the withdrawal deadline, it is sufficient for you to send your communication concerning your exercise of the right of withdrawal **before** the withdrawal period has expired.
#### Effects of Withdrawal
If you withdraw from this contract, we shall reimburse to you **all payments received** from you, including the costs of delivery (with the exception of supplementary costs resulting from your choice of a type of delivery other than the least expensive type of standard delivery offered by us), without undue delay and in any event not later than **14 days** from the day on which we are informed about your decision to withdraw from this contract.
The **cost of returning the goods is borne by you** and will be **deducted from the refund amount**. We will carry out such reimbursement using **the same means of payment** as you used for the initial transaction, unless you have expressly agreed otherwise. We may withhold reimbursement until we have received the goods back, or you have supplied evidence of having sent back the goods, whichever is the earliest.
#### Return Address
Please send the goods back to:
> **Fanaaka Ltd**, 39a Walton Road, Woking, GU21 5DL
without undue delay and in any event not later than **14 days** from the day on which you communicate your withdrawal from this contract to us. The deadline is met if you send back the goods before the 14-day period has expired.
**You incur the cost of returning the goods; this amount will be deducted from your refund.** You are only liable for any diminished value of the goods resulting from handling beyond what is necessary to establish the nature, characteristics, and functioning of the goods.
---
### Exclusion of the Right of Withdrawal
The right of withdrawal does **not** apply in the event of delivery of:
- Goods that are not pre-produced and for which an individual selection or determination by the consumer is authoritative for their manufacture, or goods that are clearly tailored to the consumer's personal requirements.
- Goods that may perish quickly or whose use-by date would expire rapidly.
- Goods that are not suitable for return for reasons of health protection or hygiene, if their seal has been broken after delivery.
- Goods that were, after delivery, inseparably mixed with other goods.
---
### Model Withdrawal Form
*(Should you wish to cancel your contract with The Pet Loft, please complete this form and return it to:)*
**To:** The Pet Loft UK, Customer Service, 39a Walton Road, Woking, GU21 5DL
**Email:** [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk)
I/We (\*) hereby give notice that I/We (\*) withdraw from my/our (\*) contract of sale of the following goods (\*) / for the provision of the following service (\*):
- **Ordered on (\*):** _______________
- **Received on (\*):** _______________
- **Name of consumer(s):** _______________
- **Address of consumer(s):** _______________
- **Signature of consumer(s):** _______________ *(only if this form is submitted on paper)*
- **Date:** _______________
*(\*) Delete as appropriate.*
---
## 7. Guarantee and Liability
The **statutory guarantee provisions** apply.
**Veterinary diet feed** should only be used where recommended and under regular monitoring by a veterinarian. The veterinarian should be visited regularly (every 6 months) during the feeding period for check-up examinations, and without delay in the event of any deterioration in the domestic pet's condition. The Pet Loft is not liable for the consequences of inappropriate or unnecessary use of veterinary diet feed.
**Medicines** should only be used as recommended and under regular supervision by the family veterinarian. The family veterinarian should be consulted regularly during use for check-ups, and immediately if the pet's health deteriorates. The Pet Loft accepts no liability for the consequences of improper or medically undeclared use of medicines.
---
## 8. Payment Methods, Vouchers, Default Interest & Invoices
### 8.1 Payment Methods
We offer the following payment options:
- **a. Credit and/or debit card**
- **b. Payment in advance**
- **c. PayPal**
- **d. Apple Pay**
We reserve the right, for each order and in individual cases, or depending on the delivery method selected by the customer, not to offer certain payment methods or to accept only certain payment methods, and to refer to alternative payment methods. **Payment by cash or cheque is not possible**, and The Pet Loft is not liable for any loss in such cases.
#### a. Payment by Credit Card
If payment is made by credit card, the amount will be debited within **one week** after the goods have been dispatched. We accept **MasterCard, Visa, Diners Club, and American Express**.
#### b. Payment in Advance
If the customer wishes to pay in advance, the invoice amount is to be transferred to one of the accounts listed below within **7 days** of receipt of the payment information. The goods will only be dispatched **after receipt of payment**. If full payment is not received within seven days of the payment information being sent, the order will be cancelled.
**Our bank details:**
| Field | Details |
|---|---|
| Account name | Fanaaka Ltd |
| Sort code | 23-11-85 |
| Account number | 20952130 |
#### c. Payment via PayPal
You pay directly via your PayPal account. After submitting your order, you will be redirected to PayPal to authorise the order value. As soon as our PayPal account has been notified of your authorisation, shipment will take place — depending on the delivery time indicated for the item. Your PayPal account will be debited with the actual invoice amount (after deduction of any discounts, gift vouchers, etc.) immediately after authorisation.
#### d. Payment via Apple Pay
Apple Pay is available as a payment method for **iOS devices** on our website. You can select this payment method in the checkout area via Safari, and pay with a linked payment card. After submitting your order, you will be redirected to Apple to authorise payment. Once we are notified of your authorisation, shipping will begin — depending on the shipping time indicated on the product. The actual invoice amount, minus any discounts or vouchers, will be debited immediately after authorisation.
### 8.2 Vouchers
When redeeming promotional vouchers, the specifically applicable redemption conditions must be observed. The relevant information can be found on the vouchers themselves.
### 8.3 Default Interest and Other Default Damages
If the customer is in **default of payment**, the purchase price shall be subject to interest at the **statutory default interest rate** during the period of default. The Pet Loft reserves the right to claim higher damages for default, subject to proof.
### 8.4 Invoices
The Pet Loft has the right to invoice the customer **electronically**. Electronic invoices will be sent to the customer via email in **PDF format**. The invoiced sales tax does not entitle the customer to an input tax deduction.
---
## 9. Data Protection
The Pet Loft takes the **protection of its customers' data** very seriously. The Pet Loft data protection declaration can be viewed on our **Data Privacy** page.
---
## 10. Marketing & Customer Communication
If the customer enters into a contract for the purchase of a product or service with The Pet Loft and provides their email address, The Pet Loft may use this email address for **direct advertising of similar goods or services**.
The customer has the right to **object to the use of their email address** for this purpose at any time, without incurring any costs other than the transmission costs according to the base rates. Each email contains an **unsubscribe link** for this purpose. Alternatively, the objection can be submitted at any time by email to [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk).
---
## 12. The Pet Loft Subscription Programme
### 12.1 General
The **Pet Loft Repeat** (hereinafter: **"subscription"**) allows registered customers to set up regular, automated orders for subscription-eligible items to be delivered at pre-determined intervals, without the need for manual repeat orders. All short-term or temporary promotional items are excluded from the subscription.
Subscription-eligible items that are part of a confirmed or completed order can be converted into the Pet Loft Repeat in your **"My Pet Loft"** customer account under **"My Orders"**.
Each automated subscription order constitutes a **binding offer** to The Pet Loft to conclude a sales contract. The contract is only concluded when The Pet Loft ships the ordered item to the customer and confirms dispatch by email (**dispatch confirmation**) within five working days of receipt of the automated order. Should the customer not receive confirmation of dispatch within the aforementioned period, a contract does not come into effect.
Before an automated order is processed, The Pet Loft will send the customer a **reminder email** allowing the customer to cancel or change the order.
### 12.2 Subscription Discount
The Pet Loft offers a **subscription discount** on the current standard price of specific items. Information about discount levels and eligible items can be found on the Pet Loft Repeat FAQ page. The subscription discount applied is the level of discount valid for subscription products at the time the order is processed. Certain subscription and product details (including price, discount, and availability) may change over time. Each subscription order is subject to the subscription and product details that currently apply. The Pet Loft reserves the right to **alter the subscription discount at any time**. The subscription discount cannot be combined with other discounts.
### 12.3 Availability of Goods
Should a particular item in your subscription order be **out of stock** on the scheduled delivery date, the order for that item will be automatically cancelled.
### 12.4 Duration, Changes, and Termination of Subscription
The subscription has **no minimum term**. Delivery intervals can be freely selected in weeks, but must be a minimum of **3 weeks** and a maximum of **12 weeks**. Changes, pausing, and cancellation of the subscription are possible at any time in the customer account under **"The Pet Loft Repeat"**.
The Pet Loft may amend these Terms & Conditions for the Pet Loft Repeat at any time by publishing the updated Terms & Conditions on [www.thepetloft.co.uk](http://www.thepetloft.co.uk) and by notifying the customer in advance of any significant changes. By continuing participation in the Pet Loft Repeat subscription service, the customer agrees to these changes. If the customer does not agree to any changes, the customer must cancel the subscription. The Pet Loft is entitled to cancel a subscription in writing at any time without stating a reason.
### 12.5 Payment Methods
Items ordered as part of the subscription service can only be paid for by **debit/credit card** or **PayPal**. The prerequisite for payment by these methods is that the data in the customer account is up-to-date and complete.
### 12.6 Miscellaneous
Should any provision in these Terms & Conditions be found to be void, invalid, or for any reason unenforceable, the validity and enforceability of the remaining Terms & Conditions shall not be affected thereby.
---
## 14. Alternative Dispute Resolution
We are neither willing nor obliged to participate in dispute resolution proceedings before a consumer arbitration board. Nevertheless, we endeavour to find an **amicable solution** to any differences of opinion with our customers. If a customer is not satisfied with one of our offers, they are welcome to contact us at [service@thepetloft.co.uk](mailto:service@thepetloft.co.uk).
---
## 15. Final Provisions
Should any provision of these Terms and Conditions be or become invalid or unenforceable, the validity or enforceability of the other provisions shall not be affected thereby.
**United Kingdom law** applies, by way of exclusion of the UN Convention on Contracts for the International Sale of Goods (CISG). This choice of applicable law only applies in so far as the protection granted by mandatory provisions of the law of the state in which the consumer has their habitual residence at the time of their order is not withdrawn.
---
## 16. Printed Version of the General Terms and Conditions
To view a printer-friendly version of these General Terms and Conditions, click on the **printer icon** at the top of the page, in the upper right corner. Alternatively, to save a copy to your device, click on the **PDF icon**.
To open these Terms and Conditions as a PDF file, you will need **Adobe Reader**, which can be downloaded free of charge.
---
*Status of these General Terms and Conditions of Business: 02 February 2026.*

View File

@@ -0,0 +1,25 @@
---
title: Privacy Policy
description: How The Pet Loft collects, uses, and protects your personal information.
lastUpdated: March 2025
---
## Information we collect
We collect information you provide when you create an account, place an order, or contact us. This may include your name, email address, delivery address, and payment details as necessary to fulfil your order.
## How we use your information
We use your information to process orders, communicate with you about your account and orders, improve our services, and comply with legal obligations. We do not sell your personal data to third parties.
## Data security
We take reasonable technical and organisational measures to protect your personal data against unauthorised access, loss, or misuse.
## Your rights
You may request access to, correction of, or deletion of your personal data in line with applicable law. Contact us to exercise these rights.
## Updates
We may update this privacy policy from time to time. The “Last updated” date at the top of this page will be revised when changes are made.

View File

@@ -0,0 +1,21 @@
---
title: Terms of Service
description: The Pet Loft terms of service and conditions of use for our website and services.
lastUpdated: March 2025
---
## Acceptance of terms
By accessing and using The Pet Loft website and services, you agree to be bound by these Terms of Service. If you do not agree, please do not use our site.
## Use of the service
You may use our website for lawful purposes only. You must not use the site in any way that could damage, disable, or impair the service or interfere with any other partys use of the site.
## Orders and payment
When you place an order, you are offering to purchase goods subject to these terms. We reserve the right to refuse or cancel orders at our discretion. Payment is due at checkout as specified.
## Changes
We may update these terms from time to time. The “Last updated” date at the top of this page will be revised when changes are made. Continued use of the site after changes constitutes acceptance of the updated terms.

View File

@@ -19,7 +19,9 @@
"@stripe/react-stripe-js": "^5.6.0", "@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.8.0", "@stripe/stripe-js": "^8.8.0",
"framer-motion": "^11.0.0", "framer-motion": "^11.0.0",
"gray-matter": "^4.0.3",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"react-markdown": "^10.1.0" "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.0"
} }
} }

View File

@@ -3,6 +3,7 @@ import { DM_Sans, Fraunces } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs"; import { ClerkProvider } from "@clerk/nextjs";
import { ConvexClientProvider } from "@repo/convex"; import { ConvexClientProvider } from "@repo/convex";
import { CartUIProvider } from "../components/cart/CartUIProvider"; import { CartUIProvider } from "../components/cart/CartUIProvider";
import { AnnouncementBar } from "../components/layout/AnnouncementBar";
import { Header } from "../components/layout/header/Header"; import { Header } from "../components/layout/header/Header";
import { SessionCartMerge } from "../lib/session/SessionCartMerge"; import { SessionCartMerge } from "../lib/session/SessionCartMerge";
import { StoreUserSync } from "../lib/session/StoreUserSync"; import { StoreUserSync } from "../lib/session/StoreUserSync";
@@ -18,7 +19,7 @@ const dmSans = DM_Sans({
const fraunces = Fraunces({ const fraunces = Fraunces({
subsets: ["latin"], subsets: ["latin"],
weight: ["400", "600", "700"], weight: ["100", "400", "600", "700"],
variable: "--font-fraunces", variable: "--font-fraunces",
}); });
@@ -45,6 +46,7 @@ export default function RootLayout({
<SessionCartMerge /> <SessionCartMerge />
<StoreUserSync /> <StoreUserSync />
<CartUIProvider> <CartUIProvider>
<AnnouncementBar />
<Header /> <Header />
{children} {children}
<Footer /> <Footer />

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("data-protection");
return {
title: doc?.data.title ?? "Data Protection",
description: doc?.data.description,
};
}
export default function DataProtectionPage() {
return <LegalDocPage slug="data-protection" />;
}

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("general-terms-and-conditions");
return {
title: doc?.data.title ?? "General Terms and Conditions",
description: doc?.data.description,
};
}
export default function GeneralTermsAndConditionsPage() {
return <LegalDocPage slug="general-terms-and-conditions" />;
}

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("privacy-policy");
return {
title: doc?.data.title ?? "Privacy Policy",
description: doc?.data.description,
};
}
export default function PrivacyPolicyPage() {
return <LegalDocPage slug="privacy-policy" />;
}

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("return-and-refund-policy");
return {
title: doc?.data.title ?? "Return and Refund Policy",
description: doc?.data.description,
};
}
export default function ReturnAndRefundPolicyPage() {
return <LegalDocPage slug="return-and-refund-policy" />;
}

View File

@@ -0,0 +1,15 @@
import { LegalDocPage } from "@/components/legal/LegalDocPage";
import { getLegalDoc } from "@/lib/legal/getLegalDoc";
import type { Metadata } from "next";
export async function generateMetadata(): Promise<Metadata> {
const doc = getLegalDoc("terms");
return {
title: doc?.data.title ?? "Terms of Service",
description: doc?.data.description,
};
}
export default function TermsOfServicePage() {
return <LegalDocPage slug="terms-of-service" />;
}

View File

@@ -7,7 +7,7 @@ import { RecentlyAddedSection } from "../components/sections/hompepage/products-
import { SpecialOffersSection } from "../components/sections/hompepage/products-sections/special-offers/SpecialOffersSection"; import { SpecialOffersSection } from "../components/sections/hompepage/products-sections/special-offers/SpecialOffersSection";
import { TopPicksSection } from "../components/sections/hompepage/products-sections/top-picks/TopPicsSection"; import { TopPicksSection } from "../components/sections/hompepage/products-sections/top-picks/TopPicsSection";
import { WishlistSection } from "../components/sections/hompepage/wishlist/WishlistSection"; import { WishlistSection } from "../components/sections/hompepage/wishlist/WishlistSection";
import { CustomerConfidenceBooster } from "../components/sections/CustomerConfidenceBooster"; import { TrustAndCredibilitySection } from "../components/sections/TrustAndCredibility";
import { Toast } from "@heroui/react"; import { Toast } from "@heroui/react";
export default function HomePage() { export default function HomePage() {
@@ -21,7 +21,7 @@ export default function HomePage() {
<RecentlyAddedSection /> <RecentlyAddedSection />
<SpecialOffersSection /> <SpecialOffersSection />
<TopPicksSection /> <TopPicksSection />
<CustomerConfidenceBooster /> <TrustAndCredibilitySection />
<NewsletterSection /> <NewsletterSection />
</main> </main>
); );

View File

@@ -0,0 +1,152 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ContactForm } from "@/components/contact/ContactForm";
export const metadata: Metadata = {
title: "Contact Us",
description:
"Get in touch with The Pet Loft. Postal address, support and service emails, and send us an inquiry.",
};
function FacebookIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
);
}
function InstagramIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
);
}
function TwitterIcon() {
return (
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}
const socialLinks = [
{ href: "https://facebook.com", label: "Facebook", icon: FacebookIcon },
{ href: "https://instagram.com", label: "Instagram", icon: InstagramIcon },
{ href: "https://twitter.com", label: "Twitter / X", icon: TwitterIcon },
];
export default function ContactUsPage() {
return (
<main className="mx-auto min-w-0 max-w-[1400px] px-4 py-8 md:px-6 md:py-12">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
Contact Us
</h1>
<p className="mt-2 text-[#3d5554]">
We&apos;d love to hear from you. Use the form to send an inquiry or find our details below.
</p>
<div className="mt-8 grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-16">
{/* Left column: address, emails, socials */}
<section className="flex flex-col gap-8" aria-label="Contact details">
<section aria-labelledby="postal-heading">
<h2
id="postal-heading"
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
>
Postal address
</h2>
<address className="mt-2 not-italic text-[#1a2e2d] leading-relaxed">
The Pet Loft
<br />
123 High Street
<br />
London, SW1A 1AA
<br />
United Kingdom
</address>
</section>
<section aria-labelledby="emails-heading">
<h2
id="emails-heading"
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
>
Support and service emails
</h2>
<ul className="mt-2 space-y-1 text-[#1a2e2d]">
<li>
<a
href="mailto:support@thepetloft.com"
className="text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
support@thepetloft.com
</a>
<span className="ml-1 text-[#3d5554]"> general support</span>
</li>
<li>
<a
href="mailto:service@thepetloft.com"
className="text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
service@thepetloft.com
</a>
<span className="ml-1 text-[#3d5554]"> orders &amp; delivery</span>
</li>
</ul>
</section>
<section aria-labelledby="follow-heading">
<h2
id="follow-heading"
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
>
Follow us
</h2>
<div className="mt-2 flex items-center gap-3">
{socialLinks.map(({ href, label, icon: Icon }) => (
<a
key={label}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="flex h-9 w-9 items-center justify-center rounded-full bg-[#f0f8f7] text-[#3d5554] transition-colors hover:bg-[#e8f7f6] hover:text-[#236f6b]"
>
<Icon />
</a>
))}
</div>
</section>
</section>
{/* Right column: Inquiries form */}
<section aria-labelledby="inquiries-heading">
<h2
id="inquiries-heading"
className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]"
>
Inquiries
</h2>
<p className="mt-1 text-sm text-[#3d5554]">
Send us a message and we&apos;ll get back to you as soon as we can.
</p>
<div className="mt-5">
<ContactForm />
</div>
</section>
</div>
<p className="mt-10 text-sm text-[#3d5554]">
<Link
href="/shop"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Back to shop
</Link>
</p>
</main>
);
}

View File

@@ -0,0 +1,44 @@
import Link from "next/link";
import { FaqPageView } from "@/components/support/FaqPageView";
import { getFaqSections } from "@/lib/faq/getFaqSections";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "FAQs",
description:
"Frequently asked questions about ordering, shipping, returns, your account, and how to contact The Pet Loft.",
};
export default function FaqsPage() {
const sections = getFaqSections();
return (
<main className="mx-auto max-w-4xl px-4 py-8 md:px-6 md:py-12">
<Link
href="/shop"
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
>
Back to Shop
</Link>
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
Frequently asked questions
</h1>
<p className="mt-2 text-[#3d5554]">
Select a topic below to see common questions and answers.
</p>
<div className="mt-8">
<FaqPageView sections={sections} lastUpdated="March 2025" />
</div>
<p className="mt-10 text-sm text-[#3d5554]">
Can&apos;t find what you need?{" "}
<Link
href="/support/contact-us"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</main>
);
}

View File

@@ -0,0 +1,72 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Payment Security",
description:
"The Pet Loft payment security: how we keep your payment details safe with industry-standard encryption.",
};
export default function PaymentSecurityPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
<Link
href="/shop"
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
>
Back to Shop
</Link>
<article className="prose prose-[#1a2e2d] max-w-none">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
Payment Security
</h1>
<p className="mt-2 text-[#3d5554]">Last updated: March 2025</p>
<section className="mt-6 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Secure Checkout
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
All payments are processed securely through Stripe, a PCI-compliant
payment provider trusted by millions of businesses worldwide. We
never store your full card details on our servers.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Industry Standard Protection
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
Our checkout uses TLS encryption to protect your data in transit.
Stripe is certified to PCI Service Provider Level 1, the highest
standard in the payments industry. Your card information is
tokenised and never exposed to our systems.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Accepted Payment Methods
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
We accept major credit and debit cards (Visa, Mastercard, Discover),
Apple Pay, Google Pay, and Klarna. Choose your preferred method at
checkout.
</p>
</section>
<p className="mt-10 text-sm text-[#3d5554]">
Questions about security?{" "}
<Link
href="/contact"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</article>
</main>
);
}

View File

@@ -0,0 +1,131 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Returns & Refunds Policy",
description:
"The Pet Loft returns policy: how to return items, refund process, and conditions for easy returns.",
};
export default function ReturnsPolicyPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
<Link
href="/shop"
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
>
Back to Shop
</Link>
<article className="prose prose-[#1a2e2d] max-w-none">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
Returns & Refunds Policy
</h1>
<p className="mt-2 text-[#3d5554]">Last updated: 02 February 2026</p>
<section className="mt-6 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Right of Withdrawal
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
You have the right to withdraw from your contract within 14 days
without giving any reason. The withdrawal period expires 14 days
from the day on which you (or a third party indicated by you, other
than the carrier) acquire physical possession of the goods.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
How to withdraw
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
To exercise the right of withdrawal, you must inform us at The Pet
Loft UK, Customer Services, 39a Walton Road, Woking, GU21 5DL, or by
email at{" "}
<a
href="mailto:service@thepetloft.co.uk"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
service@thepetloft.co.uk
</a>{" "}
of your decision by an unequivocal statement (e.g. a letter by post
or email). You may use the model withdrawal form, but it is not
obligatory. To meet the deadline, it is sufficient for you to send
your communication before the withdrawal period has expired.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Effects of withdrawal
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
If you withdraw, we shall reimburse all payments received from you,
including delivery costs (except supplementary costs from choosing a
delivery type other than our least expensive standard option),
without undue delay and in any event not later than 14 days from the
day we are informed of your withdrawal. The cost of returning the
goods is borne by you and will be deducted from the refund amount.
We will use the same means of payment as you used for the initial
transaction unless you have expressly agreed otherwise. We may
withhold reimbursement until we have received the goods back or you
have supplied evidence of having sent them back, whichever is the
earliest.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Return address
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
Please send the goods back to: <strong>Fanaaka Ltd</strong>, 39a
Walton Road, Woking, GU21 5DL without undue delay and in any event
not later than 14 days from the day you communicate your withdrawal.
The deadline is met if you send the goods back before the 14-day
period has expired. You incur the cost of returning the goods; this
amount will be deducted from your refund. You are only liable for
any diminished value resulting from handling
beyond what is necessary to establish the nature, characteristics,
and functioning of the goods.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Exclusions
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
The right of withdrawal does not apply to:
</p>
<ul className="list-inside list-disc space-y-1 text-[#1a2e2d]">
<li>
Goods made to order or clearly tailored to your personal
requirements
</li>
<li>
Goods that may perish quickly or whose use-by date would expire
rapidly
</li>
<li>
Goods not suitable for return for reasons of health or hygiene if
their seal has been broken after delivery
</li>
<li>Goods that were, after delivery, inseparably mixed with other goods</li>
</ul>
</section>
<p className="mt-10 text-sm text-[#3d5554]">
Need help?{" "}
<Link
href="/support/contact-us"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</article>
</main>
);
}

View File

@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "Shipping Policy",
description:
"The Pet Loft shipping policy: free delivery on orders over £40, delivery times, and carrier information.",
};
export default function ShippingPolicyPage() {
return (
<main className="mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
<Link
href="/shop"
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
>
Back to Shop
</Link>
<article className="prose prose-[#1a2e2d] max-w-none">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
Shipping Policy
</h1>
<p className="mt-2 text-[#3d5554]">Last updated: March 2025</p>
<section className="mt-6 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Free Delivery on Orders Over £40
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
We offer free standard delivery on all orders over £40. The discount
is automatically applied at checkout no code required.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Delivery Times
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
Standard delivery typically takes 35 working days for UK mainland.
We partner with trusted carriers including DPD and Evri to get your
pet supplies to you safely and on time.
</p>
</section>
<section className="mt-8 space-y-4">
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
Terms & Conditions
</h2>
<p className="text-[#1a2e2d] leading-relaxed">
Free shipping applies to UK mainland only. Orders under £40 may
incur a delivery charge. Delivery times are estimates and may vary
during peak periods. We reserve the right to exclude certain items
from free delivery promotions.
</p>
</section>
<p className="mt-10 text-sm text-[#3d5554]">
Questions?{" "}
<Link
href="/contact"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</article>
</main>
);
}

View File

@@ -0,0 +1,230 @@
"use client";
import { useState } from "react";
import { useMutation } from "convex/react";
import { api } from "../../../../../convex/_generated/api";
import type { Key } from "react-aria";
import {
Form,
TextField,
Label,
Input,
TextArea,
FieldError,
Button,
Spinner,
toast,
Select,
ListBox,
} from "@heroui/react";
const TOPIC_OPTIONS = [
{ key: "products", label: "Products" },
{ key: "orders", label: "Orders" },
{ key: "support", label: "Support" },
{ key: "other", label: "Other" },
] as const;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const MAX_NAME = 200;
const MAX_EMAIL = 254;
const MAX_MESSAGE = 5000;
export function ContactForm() {
const submitMessage = useMutation(api.messages.submit);
const [isSubmitting, setIsSubmitting] = useState(false);
const [topicKey, setTopicKey] = useState<Key | null>(null);
const [topicError, setTopicError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setTopicError(null);
const form = e.currentTarget;
const formData = new FormData(form);
const fullName = (formData.get("fullName") as string)?.trim() ?? "";
const email = (formData.get("email") as string)?.trim() ?? "";
const message = (formData.get("message") as string)?.trim() ?? "";
// Client-side validation
if (!fullName) {
toast.danger("Please enter your full name.");
return;
}
if (fullName.length > MAX_NAME) {
toast.danger(`Full name must be at most ${MAX_NAME} characters.`);
return;
}
if (!email) {
toast.danger("Please enter your work email.");
return;
}
if (!EMAIL_REGEX.test(email)) {
toast.danger("Please enter a valid email address.");
return;
}
if (email.length > MAX_EMAIL) {
toast.danger("Email must be at most 254 characters.");
return;
}
const topic = topicKey as string | null;
if (!topic || !TOPIC_OPTIONS.some((o) => o.key === topic)) {
setTopicError("Please select a topic.");
toast.danger("Please select a topic.");
return;
}
if (!message) {
toast.danger("Please enter your message.");
return;
}
if (message.length > MAX_MESSAGE) {
toast.danger(`Message must be at most ${MAX_MESSAGE} characters.`);
return;
}
setIsSubmitting(true);
try {
await submitMessage({
fullName,
email,
topic: topic as "products" | "orders" | "support" | "other",
message,
});
toast.success("Thank you! We've received your message and will get back to you soon.");
form.reset();
setTopicKey(null);
setTopicError(null);
} catch (err: unknown) {
const messageErr = err instanceof Error ? err.message : "Something went wrong. Please try again.";
toast.danger(messageErr);
} finally {
setIsSubmitting(false);
}
};
return (
<Form onSubmit={handleSubmit} className="flex flex-col gap-5 w-full">
<TextField
isRequired
name="fullName"
maxLength={MAX_NAME}
className="flex flex-col gap-1"
aria-required="true"
>
<Label className="text-sm font-medium text-[var(--foreground)]">
Full name <span className="text-danger">*</span>
</Label>
<Input
placeholder="First and last name"
className="bg-[var(--surface)]"
autoComplete="name"
disabled={isSubmitting}
aria-required="true"
/>
<FieldError className="text-xs text-danger" />
</TextField>
<TextField
isRequired
name="email"
type="email"
maxLength={MAX_EMAIL}
validate={(val: string) => {
if (val && !EMAIL_REGEX.test(val)) return "Please enter a valid email address.";
}}
className="flex flex-col gap-1"
aria-required="true"
>
<Label className="text-sm font-medium text-[var(--foreground)]">
Work email address <span className="text-danger">*</span>
</Label>
<Input
type="email"
placeholder="me@company.com"
className="bg-[var(--surface)]"
autoComplete="email"
disabled={isSubmitting}
aria-required="true"
/>
<FieldError className="text-xs text-danger" />
</TextField>
<div className="flex flex-col gap-1">
<Label className="text-sm font-medium text-[var(--foreground)]">
Topic <span className="text-danger">*</span>
</Label>
<Select
aria-label="Select a topic"
aria-required="true"
placeholder="Select a topic"
value={topicKey}
onChange={(value) => {
setTopicKey(value ?? null);
setTopicError(null);
}}
isDisabled={isSubmitting}
className="w-full"
>
<Select.Trigger>
<Select.Value />
<Select.Indicator />
</Select.Trigger>
<Select.Popover className="rounded-lg">
<ListBox>
{TOPIC_OPTIONS.map((opt) => (
<ListBox.Item key={opt.key} id={opt.key} textValue={opt.label}>
{opt.label}
<ListBox.ItemIndicator />
</ListBox.Item>
))}
</ListBox>
</Select.Popover>
</Select>
{topicError && (
<p className="text-xs text-danger mt-1" role="alert">
{topicError}
</p>
)}
</div>
<TextField
isRequired
name="message"
maxLength={MAX_MESSAGE}
className="flex flex-col gap-1"
aria-required="true"
>
<Label className="text-sm font-medium text-[var(--foreground)]">
Your message <span className="text-danger">*</span>
</Label>
<TextArea
rows={5}
placeholder="Write your message"
className="bg-[var(--surface)]"
disabled={isSubmitting}
aria-required="true"
/>
<FieldError className="text-xs text-danger" />
</TextField>
<Button
type="submit"
isDisabled={isSubmitting}
isPending={isSubmitting}
className="bg-[#f4a13a] text-[#1a2e2d] font-medium w-full md:w-auto md:self-start mt-1"
aria-busy={isSubmitting}
>
{({ isPending }: { isPending: boolean }) =>
isPending ? (
<>
<Spinner color="current" size="sm" />
Submitting
</>
) : (
"Submit"
)
}
</Button>
</Form>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
const PROMOS = [
{
id: "free-shipping",
message: "Free delivery on orders over £40. Automatically applied at checkout.",
href: "/shop",
},
{
id: "first-order",
message: "Sign up to our newsletter to get 10% off your first order.",
href: "/#newsletter",
},
{
id: "reorders",
message: "5% off on re-orders over £30. Automatically applied at checkout.",
href: "/shop",
},
] as const;
const ROTATION_INTERVAL_MS = 6000;
export function AnnouncementBar() {
const [index, setIndex] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setIndex((i) => (i + 1) % PROMOS.length);
}, ROTATION_INTERVAL_MS);
return () => clearInterval(id);
}, []);
const promo = PROMOS[index];
return (
<div
className="w-full border-b border-[#e8e8e8] bg-[#f4a13a]"
role="region"
aria-label="Promotional offers"
>
<div className="mx-auto flex max-w-[1400px] items-center justify-center px-4 py-2.5">
<Link
href={promo.href}
className="text-center font-sans text-xs font-medium text-[#3d5554] transition-colors hover:text-[#236f6b] md:text-sm"
>
<span className="text-[#f2705a]">{promo.id === "free-shipping" ? "★ " : ""}</span>
{promo.message}
</Link>
</div>
</div>
);
}

View File

@@ -8,10 +8,14 @@ interface BrandLogoProps {
export function BrandLogo({ size = 28, textClassName }: BrandLogoProps) { export function BrandLogo({ size = 28, textClassName }: BrandLogoProps) {
return ( return (
<Link href="/" className="flex shrink-0 items-center gap-2"> <Link
href="/"
className="flex shrink-0 flex-row items-center gap-2"
aria-label="The Pet Loft - Home"
>
<Image <Image
src="/branding/logo.svg" src="/branding/logo.svg"
alt="" alt="The Pet Loft"
width={size} width={size}
height={size} height={size}
className="shrink-0" className="shrink-0"

View File

@@ -47,48 +47,15 @@ function TwitterIcon() {
} }
const shopLinks = [ const shopLinks = [
{ label: "All Products", href: "/shop" }, { label: "Pet Toys", href: "/shop/toys" },
{ label: "Dog Food", href: "/shop/dogs/dry-food" }, { label: "Pet Treats", href: "/shop/treats" },
{ label: "Cat Food", href: "/shop/cats/dry-food" }, { label: "Cats Food", href: "/shop/cats/cat-dry-food" },
{ label: "Treats & Snacks", href: "/shop/dogs/treats" }, { label: "Dogs Food", href: "/shop/dogs/dog-dry-food" },
{ label: "Toys", href: "/shop/dogs/toys" }, { label: "Cat Grooming & Care", href: "/shop/cats/cat-feliway-care" },
{ label: "Beds & Baskets", href: "/shop/dogs/beds-and-baskets" }, { label: "Dogs Grooming & Care", href: "/shop/dogs/dog-grooming-care" },
{ label: "Grooming & Care", href: "/shop/dogs/grooming-and-care" },
{ label: "Leads & Collars", href: "/shop/dogs/leads-and-collars" },
{ label: "Bowls & Feeders", href: "/shop/dogs/bowls-and-feeders" },
{ label: "Flea & Worming", href: "/shop/dogs/flea-tick-and-worming-treatments" },
{ label: "Clothing", href: "/shop/dogs/clothing" },
]; ];
const specialtyGroups = [ const specialtyGroups = [
{
heading: "Brands",
links: [
{ label: "Almo Nature", href: "/brands/almo-nature" },
{ label: "Applaws", href: "/brands/applaws" },
{ label: "Arden Grange", href: "/brands/arden-grange" },
{ label: "Shop All", href: "/shop" },
],
},
{
heading: "Accessories",
links: [
{ label: "Crates & Travel", href: "/shop/dogs/crates-and-travel-accessories" },
{ label: "Kennels & Gates", href: "/shop/dogs/kennels-flaps-and-gates" },
{ label: "Trees & Scratching", href: "/shop/cats/trees-and-scratching-posts" },
],
},
];
const engagementGroups = [
{
heading: "Community",
links: [
{ label: "Adopt a Pet", href: "/community/adopt" },
{ label: "Pet Pharmacy", href: "/pharmacy" },
{ label: "Pet Services", href: "/services" },
],
},
{ {
heading: "Promotions", heading: "Promotions",
links: [ links: [
@@ -99,30 +66,34 @@ const engagementGroups = [
}, },
]; ];
const utilityGroups = [ const engagementGroups = [
{
heading: "Content",
links: [
{ label: "Blog", href: "/blog" },
{ label: "Tips & Tricks", href: "/tips" },
{ label: "Pet Guides", href: "/guides" },
],
},
{ {
heading: "Support", heading: "Support",
links: [ links: [
{ label: "Order Tracking", href: "/account/orders" }, { label: "Order Tracking", href: "/account/orders" },
{ label: "Shipping Info", href: "/support/shipping" }, { label: "Shipping", href: "/support/shipping" },
{ label: "Returns & Refunds", href: "/support/returns" }, { label: "Returns & Refunds", href: "/support/returns" },
{ label: "Payment Security", href: "/support/payment-security" },
{ label: "FAQs", href: "/support/faqs" }, { label: "FAQs", href: "/support/faqs" },
], ],
}, },
];
const utilityGroups = [
// {
// heading: "Content",
// links: [
// { label: "Blog", href: "/blog" },
// { label: "Tips & Tricks", href: "/tips" },
// { label: "Pet Guides", href: "/guides" },
// ],
// },
{ {
heading: "Company", heading: "Company",
links: [ links: [
{ label: "About Us", href: "/about" }, // { label: "About Us", href: "/about" },
{ label: "Contact Us", href: "/contact" }, { label: "Contact Us", href: "/support/contact-us" },
{ label: "Careers", href: "/careers" }, { label: "General Terms and Conditions", href: "/legal/general-terms-and-conditions" },
], ],
}, },
]; ];
@@ -163,10 +134,7 @@ export function Footer() {
{/* Brand & Social */} {/* Brand & Social */}
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<BrandLogo <BrandLogo size={40} />
size={30}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
<p className="mt-3 text-sm leading-relaxed text-[#3d5554]"> <p className="mt-3 text-sm leading-relaxed text-[#3d5554]">
Your trusted partner for premium pet supplies. Healthy pets, Your trusted partner for premium pet supplies. Healthy pets,
happy homes from nutrition to play, we&apos;ve got it all. happy homes from nutrition to play, we&apos;ve got it all.
@@ -219,12 +187,6 @@ export function Footer() {
</li> </li>
))} ))}
</ul> </ul>
<a
href="/special-offers"
className="mt-3 inline-block text-sm font-semibold text-[#f2705a] transition-colors hover:text-[#e05a42]"
>
Sale
</a>
</div> </div>
{/* Column 2 — Specialty */} {/* Column 2 — Specialty */}
@@ -300,20 +262,26 @@ export function Footer() {
&copy; {new Date().getFullYear()} The Pet Loft. All rights reserved. &copy; {new Date().getFullYear()} The Pet Loft. All rights reserved.
</p> </p>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<a <a
href="/terms" href="/legal/return-and-refund-policy"
className="text-xs text-white/80 transition-colors hover:text-white" className="text-xs text-white/80 transition-colors hover:text-white"
> >
Terms of Use Return & Refund Policy
</a> </a>
<a <a
href="/privacy" href="/legal/terms-of-service"
className="text-xs text-white/80 transition-colors hover:text-white"
>
Terms of Service
</a>
<a
href="/legal/privacy-policy"
className="text-xs text-white/80 transition-colors hover:text-white" className="text-xs text-white/80 transition-colors hover:text-white"
> >
Privacy Policy Privacy Policy
</a> </a>
<a <a
href="/data-protection" href="/legal/data-protection"
className="text-xs text-white/80 transition-colors hover:text-white" className="text-xs text-white/80 transition-colors hover:text-white"
> >
Data Protection Data Protection

View File

@@ -46,7 +46,7 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
className={ className={
isDesktop isDesktop
? "relative flex w-full max-w-2xl items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md" ? "relative flex w-full max-w-2xl items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
: "relative flex items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] p-1.5 shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md" : "relative flex items-center rounded-full border border-[#d9e8e7] bg-[#f9fcfb] py-3 px-3 shadow-sm transition-shadow focus-within:border-[#38a99f] focus-within:shadow-md"
} }
> >
{/* Category picker */} {/* Category picker */}
@@ -144,7 +144,7 @@ export function HeaderSearchBar({ variant }: HeaderSearchBarProps) {
className={ className={
isDesktop isDesktop
? "flex-1 bg-transparent py-3 pl-4 pr-5 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8]" ? "flex-1 bg-transparent py-3 pl-4 pr-5 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8]"
: "flex-1 border-none bg-transparent pl-3 pr-4 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0" : "min-h-[44px] flex-1 border-none bg-transparent py-2 pl-3 pr-4 text-sm text-[#1a2e2d] outline-none placeholder:text-[#8aa9a8] focus:ring-0"
} }
role="combobox" role="combobox"
aria-expanded={search.isOpen && search.showResults} aria-expanded={search.isOpen && search.showResults}

View File

@@ -22,7 +22,7 @@ export function CoreBrandBar() {
<div className="w-full bg-white"> <div className="w-full bg-white">
<div className="mx-auto flex max-w-[1400px] items-center justify-between gap-8 px-6 py-4"> <div className="mx-auto flex max-w-[1400px] items-center justify-between gap-8 px-6 py-4">
{/* Logo */} {/* Logo */}
<BrandLogo size={32} /> <BrandLogo size={56} />
{/* Search Bar */} {/* Search Bar */}
<HeaderSearchBar variant="desktop" /> <HeaderSearchBar variant="desktop" />

View File

@@ -1,11 +1,9 @@
import { TopUtilityBar } from "./TopUtilityBar";
import { CoreBrandBar } from "./CoreBrandBar"; import { CoreBrandBar } from "./CoreBrandBar";
import { BottomNav } from "./BottomNav"; import { BottomNav } from "./BottomNav";
export function DesktopHeader() { export function DesktopHeader() {
return ( return (
<header className="sticky top-0 z-50 w-full shadow-sm"> <header className="sticky top-0 z-50 w-full shadow-sm">
<TopUtilityBar />
<CoreBrandBar /> <CoreBrandBar />
<BottomNav /> <BottomNav />
</header> </header>

View File

@@ -1,74 +0,0 @@
export function TopUtilityBar() {
return (
<div className="w-full bg-[#f5f5f5] border-b border-[#e8e8e8]">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-1.5 text-xs">
{/* Domain */}
<span className="tracking-wide text-[#3d5554]">www.thepetloft.com</span>
{/* Promo */}
<div className="flex items-center gap-1.5 font-medium text-[#f2705a]">
<span><strong className="text-[13px]"> 10% </strong>
off your first order</span>
<span></span>
<span><strong className="text-[13px]"> 5% </strong>
off on all Re-orders over <strong>£30</strong></span>
<span></span>
<span>Free shipping on orders over <strong>£40</strong></span>
</div>
{/* Utility links */}
<div className="flex items-center gap-5 text-[#3d5554]">
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z" />
<path d="M14.05 2a9 9 0 0 1 8 7.94" />
<path d="M14.05 6A5 5 0 0 1 18 10" />
</svg>
<span>Contact</span>
</button>
<div className="h-3 w-px bg-[#ccc]" />
<button className="flex items-center gap-1.5 transition-colors hover:text-[#1a2e2d]">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<span>EN</span>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
</div>
</div>
);
}

View File

@@ -23,10 +23,7 @@ export function MobileCoreBrandBar() {
{/* Logo and Actions Row */} {/* Logo and Actions Row */}
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
{/* Logo */} {/* Logo */}
<BrandLogo <BrandLogo size={44} />
size={26}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useRef } from "react"; import { useRef } from "react";
import { MobileUtilityBar } from "./MobileUtilityBar";
import { MobileNavButtons } from "./MobileNavButtons"; import { MobileNavButtons } from "./MobileNavButtons";
import { MobileHeaderUserAction } from "./MobileHeaderUserAction"; import { MobileHeaderUserAction } from "./MobileHeaderUserAction";
import Link from "next/link"; import Link from "next/link";
@@ -22,14 +21,10 @@ export function MobileHeader() {
return ( return (
<> <>
{/* In-flow: utility bar + logo row scroll away with the page */} {/* In-flow: logo row scrolls away with the page */}
<div className="w-full max-w-full min-w-0 overflow-x-hidden bg-white"> <div className="w-full max-w-full min-w-0 overflow-x-hidden bg-white">
<MobileUtilityBar />
<div className="flex min-w-0 items-center justify-between gap-2 border-b border-[#e8f7f6] px-4 py-3"> <div className="flex min-w-0 items-center justify-between gap-2 border-b border-[#e8f7f6] px-4 py-3">
<BrandLogo <BrandLogo size={44} />
size={26}
textClassName="font-[family-name:var(--font-fraunces)] text-xl font-bold tracking-tight text-[#236f6b]"
/>
<div className="flex shrink-0 items-center gap-3"> <div className="flex shrink-0 items-center gap-3">
<Link <Link
href="/wishlist" href="/wishlist"

View File

@@ -1,21 +0,0 @@
"use client";
const PROMO_TEXT =
"☞ 10% off your first order ★ ☞ 5% off on all Re-orders over £30 ★ Free shipping on orders over £40 ★ ";
export function MobileUtilityBar() {
return (
<div className="relative flex min-h-[2.5rem] w-full max-w-full items-center overflow-hidden border-b border-[#e8e8e8] bg-[#f5f5f5]">
<div className="absolute inset-0 flex items-center overflow-hidden" aria-hidden>
<div className="flex animate-marquee whitespace-nowrap">
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
{PROMO_TEXT}
</span>
<span className="inline-block pr-8 font-sans text-[10px] font-medium text-[#f2705a]">
{PROMO_TEXT}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { getLegalDoc, type LegalSlug } from "@/lib/legal/getLegalDoc";
import Link from "next/link";
import { notFound } from "next/navigation";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
const LEGAL_LINKS: { slug: LegalSlug; label: string }[] = [
{ slug: "terms-of-service", label: "Terms of Service" },
{ slug: "privacy-policy", label: "Privacy Policy" },
{ slug: "data-protection", label: "Data Protection" },
{ slug: "general-terms-and-conditions", label: "General Terms and Conditions" },
{ slug: "return-and-refund-policy", label: "Return and Refund Policy" },
];
const defaultTitles: Record<LegalSlug, string> = {
"terms-of-service": "Terms of Service",
"privacy-policy": "Privacy Policy",
"data-protection": "Data Protection",
"general-terms-and-conditions": "General Terms and Conditions",
"return-and-refund-policy": "Return and Refund Policy",
};
type LegalDocPageProps = {
slug: LegalSlug;
};
export function LegalDocPage({ slug }: LegalDocPageProps) {
const doc = getLegalDoc(slug);
if (!doc) notFound();
const title = doc.data.title ?? defaultTitles[slug];
const others = LEGAL_LINKS.filter((l) => l.slug !== slug);
return (
<main className="mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12">
<Link
href="/shop"
className="mb-6 inline-flex items-center gap-1 text-sm text-[#3d5554] transition-colors hover:text-[#236f6b]"
>
Back to Shop
</Link>
<article className="prose prose-[#1a2e2d] max-w-none">
<h1 className="font-[family-name:var(--font-fraunces)] text-2xl font-bold text-[#1a2e2d] md:text-3xl">
{title}
</h1>
{doc.data.lastUpdated && (
<p className="mt-2 text-[#3d5554]">
Last updated: {doc.data.lastUpdated}
</p>
)}
<div className="mt-6 [&_h2]:font-[family-name:var(--font-fraunces)] [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:text-[#236f6b] [&_h2]:mt-8 [&_h2]:first:mt-0 [&_p]:text-[#1a2e2d] [&_p]:leading-relaxed [&_p]:mb-3 [&_a]:font-medium [&_a]:text-[#38a99f] [&_a]:underline [&_a]:transition-colors [&_a:hover]:text-[#236f6b] [&_ol]:mb-3 [&_ol]:list-inside [&_ol]:list-decimal [&_ul]:mb-3 [&_ul]:list-inside [&_ul]:list-disc">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h2 className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#236f6b]">
{children}
</h2>
),
}}
>
{doc.content}
</ReactMarkdown>
</div>
<div className="mt-10 space-y-2 border-t border-[#236f6b]/20 pt-6">
{others.length > 0 && (
<p className="text-sm text-[#3d5554]">
Other policies:{" "}
{others.map((l, i) => (
<span key={l.slug}>
{i > 0 && " · "}
<Link
href={`/${l.slug}`}
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
{l.label}
</Link>
</span>
))}
</p>
)}
<p className="text-sm text-[#3d5554]">
Need help?{" "}
<Link
href="/contact"
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
Contact us
</Link>
.
</p>
</div>
</article>
</main>
);
}

View File

@@ -1,14 +1,23 @@
"use client"; "use client";
import Link from "next/link";
/** /**
* Customer confidence booster: trust badges (Free Shipping, Easy Returns, Secure Checkout). * Customer confidence booster: trust badges (Free Shipping, Easy Returns, Secure Checkout).
* Rendered below product grids on shop pages and homepage. PetPaws theme, mobile-first. * Rendered below product grids on shop pages and homepage. PetPaws theme, mobile-first.
* Each badge links to its corresponding policy page.
*/ */
export function CustomerConfidenceBooster() { export function TrustAndCredibilitySection() {
const items: { title: string; subheading: string; icon: React.ReactNode }[] = [ const items: {
title: string;
subheading: string;
href: string;
icon: React.ReactNode;
}[] = [
{ {
title: "Free Shipping", title: "Free Shipping",
subheading: "No extra costs (T&C apply)", subheading: "No extra costs (T&C apply)",
href: "/support/shipping",
icon: ( icon: (
<svg <svg
className="size-8 shrink-0" className="size-8 shrink-0"
@@ -33,6 +42,7 @@ export function CustomerConfidenceBooster() {
{ {
title: "Easy Returns", title: "Easy Returns",
subheading: "Return with ease", subheading: "Return with ease",
href: "/support/returns",
icon: ( icon: (
<svg <svg
className="size-8 shrink-0" className="size-8 shrink-0"
@@ -55,6 +65,7 @@ export function CustomerConfidenceBooster() {
{ {
title: "Secure Checkout", title: "Secure Checkout",
subheading: "Secure payment", subheading: "Secure payment",
href: "/support/payment-security",
icon: ( icon: (
<svg <svg
className="size-8 shrink-0" className="size-8 shrink-0"
@@ -75,31 +86,28 @@ export function CustomerConfidenceBooster() {
return ( return (
<section <section
aria-label="Why shop with us" aria-label="Why shop with us"
className="w-full border border-[#d9e8e7] bg-gradient-to-r from-[#e8f7f6] to-[#f0f8f7] px-4 py-6 md:px-6 md:py-8 m-16 max-w-7xl mx-auto rounded-xl" className="w-full border border-[#f4a13a] bg-gradient-to-r from-[#e8f7f6] to-[#f0f8f7] px-4 py-6 md:px-6 md:py-8 m-16 max-w-7xl mx-auto rounded-xl"
> >
<div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 md:gap-8"> <div className="mx-auto grid w-full max-w-4xl grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 md:gap-8">
{items.map(({ title, subheading, icon }) => ( {items.map(({ title, subheading, href, icon }) => (
<div <Link
key={title} key={title}
className="flex flex-col items-center gap-3 text-center" href={href}
className="flex flex-col items-center gap-3 text-center transition-colors hover:text-[#236f6b] focus:outline-none focus:ring-2 focus:ring-[#38a99f] focus:ring-offset-2 focus:ring-offset-[#e8f7f6] rounded-lg"
> >
<div <div
className="flex size-12 items-center justify-center rounded-full bg-white text-[#236f6b] shadow-sm ring-1 ring-[#d9e8e7]" className="flex size-12 items-center justify-center rounded-full bg-white text-[#236f6b] shadow-sm ring-1 ring-[#d9e8e7] transition-colors"
aria-hidden aria-hidden
> >
{icon} {icon}
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span <span className="block font-[family-name:var(--font-fraunces)] text-base font-semibold text-[#1a2e2d] md:text-lg">
className="block font-[family-name:var(--font-fraunces)] text-base font-semibold text-[#1a2e2d] md:text-lg"
>
{title} {title}
</span> </span>
<span className="block text-sm text-[#3d5554]"> <span className="block text-sm text-[#3d5554]">{subheading}</span>
{subheading}
</span>
</div> </div>
</div> </Link>
))} ))}
</div> </div>
</section> </section>

View File

@@ -44,25 +44,56 @@ export function CtaSection() {
className="mt-1 font-[family-name:var(--font-fraunces)] text-4xl font-bold leading-tight tracking-tight text-[var(--foreground)] drop-shadow-sm md:text-5xl lg:text-6xl lg:leading-tight" className="mt-1 font-[family-name:var(--font-fraunces)] text-4xl font-bold leading-tight tracking-tight text-[var(--foreground)] drop-shadow-sm md:text-5xl lg:text-6xl lg:leading-tight"
> >
<span className="relative inline-block border-b-4 border-[var(--warm)] pb-1"> <span className="relative inline-block border-b-4 border-[var(--warm)] pb-1">
45% OFF 25% OFF
</span> </span>
</h2> </h2>
<p className="mt-3 font-sans text-base text-[var(--foreground)] md:text-lg"> <p className="mt-3 font-sans text-base text-[var(--foreground)] md:text-lg">
Thousands of pet favourites Thousands of pet essentials
</p> </p>
<Link <Link
href="/shop" href="/shop"
className="mt-6 inline-flex w-fit items-center gap-1 rounded-full bg-[var(--warm)] px-6 py-3 font-sans text-sm font-medium text-[var(--neutral-900)] shadow-sm transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:shadow-md focus:outline-none focus:ring-2 focus:ring-[var(--brand)] focus:ring-offset-2" className="mt-6 inline-flex min-h-[48px] w-fit items-center justify-center gap-2 rounded-full bg-[#e89120] px-6 py-3 font-sans text-base font-semibold text-white shadow-lg transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:bg-[#d97f0f] hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-[#38a99f] focus:ring-offset-2 md:px-8 md:py-4"
>
Shop Pet Deals
<span aria-hidden></span>
</Link>
</div>
<div className="relative z-10 mt-4 flex items-center gap-1 font-sans text-[var(--muted)] text-sm" aria-hidden>
<span>Healthy pets, happy homes.</span>
</div>
</section>
{/* Doggy CTA */}
<section
aria-labelledby="cta-doggy"
className="relative h-[130px] overflow-hidden rounded-[var(--radius)] p-3 md:h-[160px] md:p-6 lg:h-auto"
>
<div className="absolute inset-0 z-0 bg-[var(--brand-dark)]/70" aria-hidden />
<div className="relative z-10">
<h2
id="cta-doggy"
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-2xl"
>
Shop Dog Essentials
</h2>
<Link
href="/shop/dogs"
className="mt-3 inline-flex min-h-[44px] w-fit items-center justify-center gap-1.5 rounded-full bg-[#f4a13a] px-4 py-2.5 font-sans text-sm font-bold text-[#1a2e2d] shadow-lg transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:bg-[#f5ad4d] hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:min-h-[48px] md:px-8 md:py-4 md:text-base"
> >
Shop Now Shop Now
<span aria-hidden></span> <span aria-hidden></span>
</Link> </Link>
</div> </div>
<div className="relative z-10 mt-4 flex items-center gap-1 font-sans text-[var(--muted)]" aria-hidden> <div className="absolute inset-0">
<span></span> <Image
<span></span> src={CTA_IMAGES.kitty}
<span></span> alt=""
<span className="opacity-60"></span> fill
className="object-cover"
sizes="(max-width: 768px) 50vw, 33vw"
/>
</div> </div>
</section> </section>
@@ -75,18 +106,15 @@ export function CtaSection() {
<div className="relative z-10"> <div className="relative z-10">
<h2 <h2
id="cta-kitty" id="cta-kitty"
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-3xl" className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-2xl"
> >
<span className="font-sans text-xs font-normal md:text-lg">Lookin for </span> Shop Cat Essentials
<span>Kitty</span>
<br />
<span className="font-sans text-xs font-normal md:text-lg"> Stuff???</span>
</h2> </h2>
<Link <Link
href="/shop/cats" href="/shop/cats"
className="mt-2 inline-flex items-center gap-1 rounded-full border-2 border-white bg-transparent px-3 py-1.5 font-sans text-xs font-medium text-white transition-[transform,opacity] duration-[var(--transition-base)] hover:scale-[1.02] hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:px-5 md:py-2.5 md:text-sm" className="mt-3 inline-flex min-h-[44px] w-fit items-center justify-center gap-1.5 rounded-full bg-[#f4a13a] px-4 py-2.5 font-sans text-sm font-bold text-[#1a2e2d] shadow-lg transition-[transform,box-shadow] duration-[var(--transition-base)] hover:scale-[1.02] hover:bg-[#f5ad4d] hover:shadow-xl focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:min-h-[48px] md:px-8 md:py-4 md:text-base"
> >
Shop here Shop Now
<span aria-hidden></span> <span aria-hidden></span>
</Link> </Link>
</div> </div>
@@ -101,40 +129,7 @@ export function CtaSection() {
</div> </div>
</section> </section>
{/* Doggy CTA */}
<section
aria-labelledby="cta-doggy"
className="relative h-[130px] overflow-hidden rounded-[var(--radius)] p-3 md:h-[160px] md:p-6 lg:h-auto"
>
<div className="absolute inset-0 z-0 bg-[var(--brand-dark)]/70" aria-hidden />
<div className="relative z-10">
<h2
id="cta-doggy"
className="font-[family-name:var(--font-fraunces)] text-lg font-bold text-white md:text-3xl"
>
<span className="font-sans text-xs font-normal md:text-lg">Lookin for </span>
<span>Doggy</span>
<br />
<span className="font-sans text-xs font-normal md:text-lg"> Stuff???</span>
</h2>
<Link
href="/shop/dogs"
className="mt-2 inline-flex items-center gap-1 rounded-full border-2 border-white bg-transparent px-3 py-1.5 font-sans text-xs font-medium text-white transition-[transform,opacity] duration-[var(--transition-base)] hover:scale-[1.02] hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-[var(--brand-dark)] md:mt-4 md:px-5 md:py-2.5 md:text-sm"
>
Shop here
<span aria-hidden></span>
</Link>
</div>
<div className="absolute inset-0">
<Image
src={CTA_IMAGES.kitty}
alt=""
fill
className="object-cover"
sizes="(max-width: 768px) 50vw, 33vw"
/>
</div>
</section>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -17,6 +17,7 @@ function EnvelopeIcon({ className }: { className?: string }) {
export function NewsletterSection() { export function NewsletterSection() {
return ( return (
<section <section
id="newsletter"
aria-label="Newsletter signup" aria-label="Newsletter signup"
className="relative max-w-7xl mx-auto w-full px-4 md:px-8 mb-12" className="relative max-w-7xl mx-auto w-full px-4 md:px-8 mb-12"
> >

View File

@@ -25,7 +25,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState"; import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState"; import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton"; import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster"; import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
const SORT_OPTIONS: SortOption[] = [ const SORT_OPTIONS: SortOption[] = [
{ value: "newest", label: "Newest" }, { value: "newest", label: "Newest" },
@@ -194,7 +194,7 @@ export function PetCategoryPage({ slug }: { slug: PetCategorySlug }) {
/> />
)} )}
<div className="mt-8"> <div className="mt-8">
<CustomerConfidenceBooster /> <TrustAndCredibilitySection />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,7 +13,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState"; import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState"; import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton"; import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster"; import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import { import {
filterStateFromSearchParams, filterStateFromSearchParams,
mergeFilterStateIntoSearchParams, mergeFilterStateIntoSearchParams,
@@ -164,7 +164,7 @@ export function RecentlyAddedPage() {
/> />
)} )}
<div className="mt-8"> <div className="mt-8">
<CustomerConfidenceBooster /> <TrustAndCredibilitySection />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -14,7 +14,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState"; import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState"; import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton"; import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster"; import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import { import {
filterStateFromSearchParams, filterStateFromSearchParams,
filterStateToSearchParams, filterStateToSearchParams,
@@ -164,7 +164,7 @@ export function ShopIndexContent() {
/> />
)} )}
<div className="mt-8"> <div className="mt-8">
<CustomerConfidenceBooster /> <TrustAndCredibilitySection />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -16,7 +16,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState"; import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState"; import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton"; import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster"; import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import { import {
filterStateFromSearchParams, filterStateFromSearchParams,
filterStateToSearchParams, filterStateToSearchParams,
@@ -211,7 +211,7 @@ export function SubCategoryPageContent({
/> />
)} )}
<div className="mt-8"> <div className="mt-8">
<CustomerConfidenceBooster /> <TrustAndCredibilitySection />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -13,7 +13,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState"; import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState"; import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton"; import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster"; import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import { import {
filterStateFromSearchParams, filterStateFromSearchParams,
mergeFilterStateIntoSearchParams, mergeFilterStateIntoSearchParams,
@@ -170,7 +170,7 @@ export function TagShopPage({
/> />
)} )}
<div className="mt-8"> <div className="mt-8">
<CustomerConfidenceBooster /> <TrustAndCredibilitySection />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -15,7 +15,7 @@ import { ShopToolbar, type SortOption } from "@/components/shop/ShopToolbar";
import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState"; import { ShopEmptyState } from "@/components/shop/state/ShopEmptyState";
import { ShopErrorState } from "@/components/shop/state/ShopErrorState"; import { ShopErrorState } from "@/components/shop/state/ShopErrorState";
import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton"; import { ShopProductGridSkeleton } from "@/components/shop/state/ShopProductGridSkeleton";
import { CustomerConfidenceBooster } from "@/components/sections/CustomerConfidenceBooster"; import { TrustAndCredibilitySection } from "@/components/sections/TrustAndCredibility";
import { import {
PET_CATEGORY_SLUGS, PET_CATEGORY_SLUGS,
TOP_CATEGORY_SLUGS, TOP_CATEGORY_SLUGS,
@@ -249,7 +249,7 @@ export function TopCategoryPage({ slug }: { slug: TopCategorySlug }) {
/> />
)} )}
<div className="mt-8"> <div className="mt-8">
<CustomerConfidenceBooster /> <TrustAndCredibilitySection />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,132 @@
"use client";
import { Accordion } from "@heroui/react";
import Link from "next/link";
import { useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { FaqSection } from "@/lib/faq/getFaqSections";
type FaqPageViewProps = {
sections: FaqSection[];
lastUpdated?: string;
};
function FaqAnswer({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
a: ({ href, children }) =>
href?.startsWith("/") ? (
<Link
href={href}
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
>
{children}
</Link>
) : (
<a
href={href}
className="font-medium text-[#38a99f] underline transition-colors hover:text-[#236f6b]"
target="_blank"
rel="noopener noreferrer"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
);
}
export function FaqPageView({ sections, lastUpdated }: FaqPageViewProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const selectedSection = useMemo(
() => (selectedId ? sections.find((s) => s.title === selectedId) ?? null : null),
[sections, selectedId]
);
const sectionId = (title: string) => title.replace(/\s+/g, "-").toLowerCase();
return (
<div className="space-y-8">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{sections.map((section) => {
const id = sectionId(section.title);
const isSelected = selectedId === section.title;
return (
<button
key={id}
type="button"
onClick={() => setSelectedId(isSelected ? null : section.title)}
className="flex flex-col items-start rounded-lg border-2 border-[#236f6b]/20 bg-[#f0f8f7] p-5 text-left transition-colors hover:border-[#38a99f] hover:bg-[#e8f7f6] focus:outline-none focus:ring-2 focus:ring-[#38a99f] focus:ring-offset-2"
aria-expanded={isSelected}
data-selected={isSelected ? "" : undefined}
style={
isSelected
? { borderColor: "#236f6b", backgroundColor: "#e8f7f6" }
: undefined
}
>
<span className="font-[family-name:var(--font-fraunces)] text-lg font-semibold text-[#1a2e2d]">
{section.title}
</span>
{section.subtitle && (
<span className="mt-1 text-sm text-[#3d5554]">
{section.subtitle}
</span>
)}
</button>
);
})}
</div>
{selectedSection && selectedSection.items.length > 0 && (
<section
id={`faq-${sectionId(selectedSection.title)}`}
aria-labelledby={`faq-heading-${sectionId(selectedSection.title)}`}
className="rounded-lg border border-[#236f6b]/20 bg-white p-4 md:p-6"
>
<h2
id={`faq-heading-${sectionId(selectedSection.title)}`}
className="font-[family-name:var(--font-fraunces)] text-xl font-semibold text-[#236f6b]"
>
{selectedSection.title}
</h2>
<Accordion
allowsMultipleExpanded
className="mt-4 w-full"
hideSeparator={false}
>
{selectedSection.items.map((item, index) => (
<Accordion.Item
key={`${sectionId(selectedSection.title)}-${index}`}
id={`${sectionId(selectedSection.title)}-q-${index}`}
>
<Accordion.Heading>
<Accordion.Trigger className="text-left text-sm font-medium text-[#1a2e2d]">
{item.question}
<Accordion.Indicator />
</Accordion.Trigger>
</Accordion.Heading>
<Accordion.Panel>
<Accordion.Body className="pb-4 pt-1 text-[#1a2e2d] leading-relaxed [&_a]:font-medium [&_a]:text-[#38a99f] [&_a]:underline [&_a:hover]:text-[#236f6b]">
<FaqAnswer content={item.answer} />
</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
</section>
)}
{lastUpdated && (
<p className="text-sm text-[#3d5554]">Last updated: {lastUpdated}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import fs from "fs";
import matter from "gray-matter";
import path from "path";
export type FaqItem = {
question: string;
answer: string;
};
export type FaqSection = {
title: string;
subtitle: string;
order: number;
items: FaqItem[];
};
function getFaqContentDir(): string {
const cwd = process.cwd();
const direct = path.join(cwd, "content", "faq");
if (fs.existsSync(direct)) return direct;
const fromStorefront = path.join(cwd, "apps", "storefront", "content", "faq");
if (fs.existsSync(fromStorefront)) return fromStorefront;
return direct;
}
/**
* Parses FAQ markdown body: each "### Question" heading starts a new Q&A;
* the following lines (until the next ### or EOF) are the answer.
*/
function parseFaqBody(body: string): FaqItem[] {
const trimmed = body.trim();
if (!trimmed) return [];
const blocks = trimmed.split(/\n(?=### )/);
const items: FaqItem[] = [];
for (const block of blocks) {
const firstNewline = block.indexOf("\n");
const firstLine = firstNewline === -1 ? block : block.slice(0, firstNewline);
const rest = firstNewline === -1 ? "" : block.slice(firstNewline + 1).trim();
const question = firstLine.replace(/^###\s*/, "").trim();
if (question) {
items.push({ question, answer: rest });
}
}
return items;
}
/**
* Reads and parses all FAQ section markdown files. Server-only; use in Server Components.
* Returns sections sorted by frontmatter `order`.
*/
export function getFaqSections(): FaqSection[] {
const contentDir = getFaqContentDir();
if (!fs.existsSync(contentDir)) return [];
const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".md"));
const sections: FaqSection[] = [];
for (const file of files) {
const filePath = path.join(contentDir, file);
try {
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
const title = (data.title as string) ?? path.basename(file, ".md");
const subtitle = (data.subtitle as string) ?? "";
const order = typeof data.order === "number" ? data.order : 999;
const items = parseFaqBody(content);
sections.push({ title, subtitle, order, items });
} catch {
// skip invalid files
}
}
sections.sort((a, b) => a.order - b.order);
return sections;
}

View File

@@ -0,0 +1,58 @@
import fs from "fs";
import matter from "gray-matter";
import path from "path";
export const LEGAL_SLUGS = [
"return-and-refund-policy",
"terms-of-service",
"privacy-policy",
"data-protection",
"general-terms-and-conditions",
] as const;
export type LegalSlug = (typeof LEGAL_SLUGS)[number];
export type LegalDocData = {
title?: string;
description?: string;
lastUpdated?: string;
};
export type LegalDoc = {
data: LegalDocData;
content: string;
};
function getContentDir(): string {
const cwd = process.cwd();
const direct = path.join(cwd, "content", "legal");
if (fs.existsSync(direct)) return direct;
const fromRoot = path.join(cwd, "apps", "storefront", "content", "legal");
if (fs.existsSync(fromRoot)) return fromRoot;
return direct;
}
/**
* Reads and parses a legal markdown file. Server-only; use in Server Components.
* Returns null if slug is invalid or file is missing (call notFound() in the page).
*/
export function getLegalDoc(slug: string): LegalDoc | null {
if (!LEGAL_SLUGS.includes(slug as LegalSlug)) return null;
const contentDir = getContentDir();
const filePath = path.join(contentDir, `${slug}.md`);
if (!fs.existsSync(filePath)) return null;
try {
const raw = fs.readFileSync(filePath, "utf-8");
const { data, content } = matter(raw);
return {
data: {
title: data.title as string | undefined,
description: data.description as string | undefined,
lastUpdated: data.lastUpdated as string | undefined,
},
content: content.trim(),
};
} catch {
return null;
}
}

View File

@@ -17,6 +17,7 @@ import type * as checkoutActions from "../checkoutActions.js";
import type * as emails from "../emails.js"; import type * as emails from "../emails.js";
import type * as fulfillmentActions from "../fulfillmentActions.js"; import type * as fulfillmentActions from "../fulfillmentActions.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as messages from "../messages.js";
import type * as model_carts from "../model/carts.js"; import type * as model_carts from "../model/carts.js";
import type * as model_categories from "../model/categories.js"; import type * as model_categories from "../model/categories.js";
import type * as model_checkout from "../model/checkout.js"; import type * as model_checkout from "../model/checkout.js";
@@ -50,6 +51,7 @@ declare const fullApi: ApiFromModules<{
emails: typeof emails; emails: typeof emails;
fulfillmentActions: typeof fulfillmentActions; fulfillmentActions: typeof fulfillmentActions;
http: typeof http; http: typeof http;
messages: typeof messages;
"model/carts": typeof model_carts; "model/carts": typeof model_carts;
"model/categories": typeof model_categories; "model/categories": typeof model_categories;
"model/checkout": typeof model_checkout; "model/checkout": typeof model_checkout;

57
convex/messages.test.ts Normal file
View File

@@ -0,0 +1,57 @@
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import { api } from "./_generated/api";
import schema from "./schema";
const modules = import.meta.glob("./**/*.ts");
describe("messages.submit", () => {
it("creates one document with correct fields for valid payload", async () => {
const t = convexTest(schema, modules);
const messageId = await t.mutation(api.messages.submit, {
fullName: "Jane Doe",
email: "jane@example.com",
topic: "support",
message: "I need help with my order.",
});
expect(messageId).toBeTruthy();
await t.run(async (ctx) => {
const doc = await ctx.db.get(messageId);
expect(doc).not.toBeNull();
expect(doc!.fullName).toBe("Jane Doe");
expect(doc!.email).toBe("jane@example.com");
expect(doc!.topic).toBe("support");
expect(doc!.message).toBe("I need help with my order.");
expect(doc!.createdAt).toBeGreaterThan(0);
expect(doc!.bookmarked).toBe(false);
});
});
it("rejects invalid topic", async () => {
const t = convexTest(schema, modules);
await expect(
t.mutation(api.messages.submit, {
fullName: "Jane Doe",
email: "jane@example.com",
topic: "invalid_topic",
message: "Hello",
}),
).rejects.toThrow();
});
it("rejects missing required fields", async () => {
const t = convexTest(schema, modules);
const invalidArgs = {
fullName: "Jane Doe",
};
await expect(
t.mutation(api.messages.submit, invalidArgs as {
fullName: string;
email: string;
topic: "products" | "orders" | "support" | "other";
message: string;
}),
).rejects.toThrow();
});
});

32
convex/messages.ts Normal file
View File

@@ -0,0 +1,32 @@
import { mutation } from "./_generated/server";
import { v } from "convex/values";
const topicValidator = v.union(
v.literal("products"),
v.literal("orders"),
v.literal("support"),
v.literal("other"),
);
/**
* Public mutation: submit a contact message. No auth required.
*/
export const submit = mutation({
args: {
fullName: v.string(),
email: v.string(),
topic: topicValidator,
message: v.string(),
},
handler: async (ctx, args) => {
const now = Date.now();
return await ctx.db.insert("messages", {
fullName: args.fullName,
email: args.email,
topic: args.topic,
message: args.message,
createdAt: now,
bookmarked: false,
});
},
});

View File

@@ -313,4 +313,23 @@ export default defineSchema({
}) })
.index("by_user", ["userId"]) .index("by_user", ["userId"])
.index("by_session", ["sessionId"]), .index("by_session", ["sessionId"]),
// ─── Contact messages ───────────────────────────────────────────────────
messages: defineTable({
fullName: v.string(),
email: v.string(),
topic: v.union(
v.literal("products"),
v.literal("orders"),
v.literal("support"),
v.literal("other"),
),
message: v.string(),
createdAt: v.number(),
bookmarked: v.optional(v.boolean()),
updatedAt: v.optional(v.number()),
})
.index("by_created_at", ["createdAt"])
.index("by_topic", ["topic"])
.index("by_bookmarked", ["bookmarked"]),
}); });

393
package-lock.json generated
View File

@@ -89,8 +89,10 @@
"@stripe/react-stripe-js": "^5.6.0", "@stripe/react-stripe-js": "^5.6.0",
"@stripe/stripe-js": "^8.8.0", "@stripe/stripe-js": "^8.8.0",
"framer-motion": "^11.0.0", "framer-motion": "^11.0.0",
"gray-matter": "^4.0.3",
"lucide-react": "^0.400.0", "lucide-react": "^0.400.0",
"react-markdown": "^10.1.0" "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.0"
} }
}, },
"apps/storefront/node_modules/@heroui/react": { "apps/storefront/node_modules/@heroui/react": {
@@ -12208,7 +12210,6 @@
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"bin": { "bin": {
"esparse": "bin/esparse.js", "esparse": "bin/esparse.js",
@@ -12424,6 +12425,18 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -13039,6 +13052,43 @@
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
} }
}, },
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/gray-matter/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/gray-matter/node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/happy-dom": { "node_modules/happy-dom": {
"version": "20.7.0", "version": "20.7.0",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz",
@@ -13581,6 +13631,15 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -14176,6 +14235,15 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -14608,6 +14676,16 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -14618,6 +14696,34 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/mdast-util-find-and-replace": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"escape-string-regexp": "^5.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mdast-util-from-markdown": { "node_modules/mdast-util-from-markdown": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
@@ -14642,6 +14748,107 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/mdast-util-gfm": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
"license": "MIT",
"dependencies": {
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-gfm-autolink-literal": "^2.0.0",
"mdast-util-gfm-footnote": "^2.0.0",
"mdast-util-gfm-strikethrough": "^2.0.0",
"mdast-util-gfm-table": "^2.0.0",
"mdast-util-gfm-task-list-item": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-autolink-literal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"ccount": "^2.0.0",
"devlop": "^1.0.0",
"mdast-util-find-and-replace": "^3.0.0",
"micromark-util-character": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.1.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-strikethrough": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-table": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"markdown-table": "^3.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-gfm-task-list-item": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"devlop": "^1.0.0",
"mdast-util-from-markdown": "^2.0.0",
"mdast-util-to-markdown": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdast-util-mdx-expression": { "node_modules/mdast-util-mdx-expression": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
@@ -14880,6 +15087,127 @@
"micromark-util-types": "^2.0.0" "micromark-util-types": "^2.0.0"
} }
}, },
"node_modules/micromark-extension-gfm": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
"license": "MIT",
"dependencies": {
"micromark-extension-gfm-autolink-literal": "^2.0.0",
"micromark-extension-gfm-footnote": "^2.0.0",
"micromark-extension-gfm-strikethrough": "^2.0.0",
"micromark-extension-gfm-table": "^2.0.0",
"micromark-extension-gfm-tagfilter": "^2.0.0",
"micromark-extension-gfm-task-list-item": "^2.0.0",
"micromark-util-combine-extensions": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-autolink-literal": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-footnote": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-core-commonmark": "^2.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-normalize-identifier": "^2.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-strikethrough": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-util-chunked": "^2.0.0",
"micromark-util-classify-character": "^2.0.0",
"micromark-util-resolve-all": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-table": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz",
"integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-tagfilter": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
"license": "MIT",
"dependencies": {
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-extension-gfm-task-list-item": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
"license": "MIT",
"dependencies": {
"devlop": "^1.0.0",
"micromark-factory-space": "^2.0.0",
"micromark-util-character": "^2.0.0",
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-factory-destination": { "node_modules/micromark-factory-destination": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
@@ -16939,6 +17267,24 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/remark-gfm": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remark-parse": { "node_modules/remark-parse": {
"version": "11.0.0", "version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
@@ -16972,6 +17318,21 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/remark-stringify": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
"license": "MIT",
"dependencies": {
"@types/mdast": "^4.0.0",
"mdast-util-to-markdown": "^2.0.0",
"unified": "^11.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/remeda": { "node_modules/remeda": {
"version": "2.33.6", "version": "2.33.6",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.6.tgz", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.6.tgz",
@@ -17292,6 +17653,19 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.4", "version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -17729,6 +18103,12 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -18016,6 +18396,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strip-final-newline": { "node_modules/strip-final-newline": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",