chore: transfer repo
This commit is contained in:
12
app/[page]/layout.tsx
Normal file
12
app/[page]/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Footer } from 'components/layout/footer';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="w-full">
|
||||
<div className="mx-8 max-w-2xl py-20 sm:mx-auto">{children}</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/[page]/opengraph-image.tsx
Normal file
11
app/[page]/opengraph-image.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getPage } from 'lib/shopify';
|
||||
import OpengraphImage from '../../components/opengraph-image';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { page: string } }) {
|
||||
const page = await getPage(params.page);
|
||||
const title = page.seo?.title || page.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
||||
45
app/[page]/page.tsx
Normal file
45
app/[page]/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import Prose from 'components/prose';
|
||||
import { getPage } from 'lib/shopify';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ page: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
return {
|
||||
title: page.seo?.title || page.title,
|
||||
description: page.seo?.description || page.bodySummary,
|
||||
openGraph: {
|
||||
publishedTime: page.createdAt,
|
||||
modifiedTime: page.updatedAt,
|
||||
type: 'article'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page(props: { params: Promise<{ page: string }> }) {
|
||||
const params = await props.params;
|
||||
const page = await getPage(params.page);
|
||||
|
||||
if (!page) return notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-8">{page.title}</h1>
|
||||
<Prose className="mb-8" html={page.body} />
|
||||
<p className="text-sm italic">
|
||||
{`This document was last updated on ${new Intl.DateTimeFormat(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(new Date(page.updatedAt))}.`}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/about/page.tsx
Normal file
11
app/about/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AboutPageContent } from '@/components/about/AboutPageContent';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'About Us | Sent',
|
||||
description: 'Learn more about Sent and our mission to create personalized gift boxes for every occasion.'
|
||||
};
|
||||
|
||||
export default function AboutPage() {
|
||||
return <AboutPageContent />;
|
||||
}
|
||||
21
app/api/products/route.ts
Normal file
21
app/api/products/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { getBoxProducts, getProducts } from 'lib/shopify';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Get query parameters
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type');
|
||||
|
||||
if (type === 'boxes') {
|
||||
const boxProducts = await getBoxProducts({});
|
||||
return NextResponse.json({ products: boxProducts });
|
||||
} else {
|
||||
const allProducts = await getProducts({});
|
||||
return NextResponse.json({ products: allProducts });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
return NextResponse.json({ error: 'Failed to fetch products' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
6
app/api/revalidate/route.ts
Normal file
6
app/api/revalidate/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { revalidate } from 'lib/shopify';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
return revalidate(req);
|
||||
}
|
||||
10
app/build-box/customize/page.tsx
Normal file
10
app/build-box/customize/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { BuildBoxCustomizePage } from "@/components/build-box/BuildBoxCustomizePage";
|
||||
import { Metadata } from "next";
|
||||
export const metadata: Metadata = {
|
||||
title: "Choose Your Box | Sent",
|
||||
description: "Select the perfect box design to complete your custom gift box."
|
||||
};
|
||||
|
||||
export default function BuildBoxCustomizeRoute() {
|
||||
return <BuildBoxCustomizePage />;
|
||||
}
|
||||
11
app/build-box/page.tsx
Normal file
11
app/build-box/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { BuildBoxPage } from "@/components/build-box/BuildBoxPage";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Build Your Box | Sent",
|
||||
description: "Create your custom gift box by selecting products you'd like to include."
|
||||
};
|
||||
|
||||
export default function BuildBoxRoute() {
|
||||
return <BuildBoxPage />;
|
||||
}
|
||||
10
app/cart/page.tsx
Normal file
10
app/cart/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import CartPage from 'components/cart/CartPage';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Cart',
|
||||
description: 'View your shopping cart and proceed to checkout.',
|
||||
};
|
||||
|
||||
export default function Cart() {
|
||||
return <CartPage />;
|
||||
}
|
||||
19
app/error.tsx
Normal file
19
app/error.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
export default function Error({ reset }: { reset: () => void }) {
|
||||
return (
|
||||
<div className="mx-auto my-4 flex max-w-xl flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 dark:border-neutral-800 dark:bg-black">
|
||||
<h2 className="text-xl font-bold">Oh no!</h2>
|
||||
<p className="my-2">
|
||||
There was an issue with our storefront. This could be a temporary issue, please try your
|
||||
action again.
|
||||
</p>
|
||||
<button
|
||||
className="mx-auto mt-4 flex w-full items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white hover:opacity-90"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
28
app/fonts.css
Normal file
28
app/fonts.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/* AlleNoire fonts */
|
||||
/* Regular */
|
||||
@font-face {
|
||||
font-family: 'AlleNoire';
|
||||
src: url('/fonts/allenoire-allenoire-regular-400.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Poppins fonts */
|
||||
/* Regular */
|
||||
@font-face {
|
||||
font-family: 'Poppins Regular';
|
||||
src: url('/fonts/Poppins-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* SemiBold */
|
||||
@font-face {
|
||||
font-family: 'Poppins SemiBold';
|
||||
src: url('/fonts/Poppins-SemiBold.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
153
app/globals.css
Normal file
153
app/globals.css
Normal file
@@ -0,0 +1,153 @@
|
||||
@import './fonts.css';
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom animation for fade-in effect */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Add custom styles below */
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
--chart-1: 12 76% 61%;
|
||||
|
||||
--chart-2: 173 58% 39%;
|
||||
|
||||
--chart-3: 197 37% 24%;
|
||||
|
||||
--chart-4: 43 74% 66%;
|
||||
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: 'AlleNoire', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'AlleNoire', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Font weight utility classes */
|
||||
.font-extrabold {
|
||||
font-family: 'AlleNoire', system-ui, sans-serif;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-family: 'AlleNoire', system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-family: 'AlleNoire', system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Set letter-spacing to 0 for all text elements */
|
||||
h1, h2, h3, h4, h5, h6, p, span, div {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
92
app/layout.tsx
Normal file
92
app/layout.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { CookieConsent } from '@/components/cookies';
|
||||
import { Footer } from '@/components/layout/footer';
|
||||
import Navbar from '@/components/layout/navbar';
|
||||
import { ReduxProvider } from '@/lib/redux/provider';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CartProvider } from 'components/cart/cart-context';
|
||||
import { getCart } from 'lib/shopify';
|
||||
import { ensureStartsWith } from 'lib/utils';
|
||||
import type { Metadata } from 'next';
|
||||
import { cookies } from 'next/headers';
|
||||
import { ReactNode } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import './globals.css';
|
||||
|
||||
// Add font loading check
|
||||
if (typeof window !== 'undefined') {
|
||||
document.fonts.ready.then(() => {
|
||||
console.log('Fonts have loaded.');
|
||||
document.fonts.forEach(font => {
|
||||
console.log(`Font family: ${font.family}, Status: ${font.status}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const { TWITTER_CREATOR, TWITTER_SITE, SITE_NAME } = process.env;
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
const twitterCreator = TWITTER_CREATOR ? ensureStartsWith(TWITTER_CREATOR, '@') : undefined;
|
||||
const twitterSite = TWITTER_SITE ? ensureStartsWith(TWITTER_SITE, 'https://') : undefined;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(baseUrl),
|
||||
title: {
|
||||
default: SITE_NAME!,
|
||||
template: `%s | ${SITE_NAME}`
|
||||
},
|
||||
robots: {
|
||||
follow: true,
|
||||
index: true
|
||||
},
|
||||
...(twitterCreator &&
|
||||
twitterSite && {
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
creator: twitterCreator,
|
||||
site: twitterSite
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const allCookies = await cookies();
|
||||
const cartId = allCookies.get('cartId')?.value;
|
||||
// Don't await the fetch, pass the Promise to the context provider
|
||||
const cart = getCart(cartId);
|
||||
|
||||
// Get locale from cookie
|
||||
const localeCookie = allCookies.get('NEXT_LOCALE')?.value;
|
||||
const locale = localeCookie || 'hr';
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<head>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css"
|
||||
integrity="sha512-KfkfwYDsLkIlwQp6LFnl8zNdLGxu9YAA1QvwINks4PhcElQSvqcyVLLD9aMhXd13uQjoXtEKNosOWaZqXgel0g=="
|
||||
crossOrigin="anonymous"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</head>
|
||||
<body className={cn("min-h-screen bg-white antialiased")}>
|
||||
<ReduxProvider>
|
||||
<CartProvider cartPromise={cart}>
|
||||
<CookieConsent>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Navbar />
|
||||
<main className="flex-grow">
|
||||
{children}
|
||||
<Toaster closeButton />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</CookieConsent>
|
||||
</CartProvider>
|
||||
</ReduxProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
8
app/opengraph-image.tsx
Normal file
8
app/opengraph-image.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import OpengraphImage from '../components/opengraph-image';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image() {
|
||||
// Default implementation for root page
|
||||
return await OpengraphImage({ title: 'Sent - Personalizirani poklon paketi' });
|
||||
}
|
||||
20
app/page.tsx
Normal file
20
app/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import NewHomePage from "@/components/home/NewHomePage";
|
||||
|
||||
// Statički metadata
|
||||
export const metadata = {
|
||||
title: 'Sent | Poklon paketi za svaku priliku',
|
||||
description: 'Otkrij čar darivanja uz našu opciju "Složi kutiju". Biraj između pažljivo odabranih proizvoda i složi poklon koji će osvojiti na prvu.',
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'Sent | Poklon paketi za svaku priliku',
|
||||
description: 'Otkrij čar darivanja uz našu opciju "Složi kutiju". Biraj između pažljivo odabranih proizvoda i složi poklon koji će osvojiti na prvu.'
|
||||
}
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<NewHomePage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
app/privacy-policy/page.tsx
Normal file
64
app/privacy-policy/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { container } from "@/lib/utils";
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<div className={`${container} py-12`}>
|
||||
<Heading level={1} className="mb-8 text-center">Privacy Policy</Heading>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Text className="mb-6">
|
||||
Last updated: {new Date().toLocaleDateString()}
|
||||
</Text>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">1. Introduction</Heading>
|
||||
<Text className="mb-4">
|
||||
Welcome to SENT. We respect your privacy and are committed to protecting your personal data.
|
||||
This privacy policy will inform you about how we look after your personal data when you visit our website
|
||||
and tell you about your privacy rights and how the law protects you.
|
||||
</Text>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">2. The Data We Collect About You</Heading>
|
||||
<Text className="mb-4">
|
||||
Personal data, or personal information, means any information about an individual from which that person can be identified.
|
||||
It does not include data where the identity has been removed (anonymous data).
|
||||
</Text>
|
||||
<Text className="mb-4">
|
||||
We may collect, use, store and transfer different kinds of personal data about you which we have grouped together as follows:
|
||||
</Text>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>Identity Data includes first name, last name, username or similar identifier.</li>
|
||||
<li>Contact Data includes billing address, delivery address, email address and telephone numbers.</li>
|
||||
<li>Financial Data includes payment card details.</li>
|
||||
<li>Transaction Data includes details about payments to and from you and other details of products you have purchased from us.</li>
|
||||
<li>Technical Data includes internet protocol (IP) address, browser type and version, time zone setting and location, browser plug-in types and versions, operating system and platform, and other technology on the devices you use to access this website.</li>
|
||||
</ul>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">3. How We Use Your Personal Data</Heading>
|
||||
<Text className="mb-4">
|
||||
We will only use your personal data when the law allows us to. Most commonly, we will use your personal data in the following circumstances:
|
||||
</Text>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>Where we need to perform the contract we are about to enter into or have entered into with you.</li>
|
||||
<li>Where it is necessary for our legitimate interests (or those of a third party) and your interests and fundamental rights do not override those interests.</li>
|
||||
<li>Where we need to comply with a legal obligation.</li>
|
||||
</ul>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">4. Data Security</Heading>
|
||||
<Text className="mb-4">
|
||||
We have put in place appropriate security measures to prevent your personal data from being accidentally lost, used or accessed in an unauthorized way, altered or disclosed.
|
||||
In addition, we limit access to your personal data to those employees, agents, contractors and other third parties who have a business need to know.
|
||||
They will only process your personal data on our instructions and they are subject to a duty of confidentiality.
|
||||
</Text>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">5. Contact Us</Heading>
|
||||
<Text className="mb-6">
|
||||
If you have any questions about this privacy policy or our privacy practices, please contact us at:
|
||||
</Text>
|
||||
<Text className="font-medium mb-1">Email: info@sentshop.com</Text>
|
||||
<Text className="font-medium mb-1">Phone: +385 1 234 5678</Text>
|
||||
<Text className="font-medium mb-6">Address: Ilica 123, 10000 Zagreb, Croatia</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
app/product/[handle]/page.tsx
Normal file
122
app/product/[handle]/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import { BackButton } from '@/components/product/BackButton';
|
||||
import { ProductDescription } from '@/components/product/ProductDescription';
|
||||
import { container } from '@/lib/utils';
|
||||
import { ProductProvider } from 'components/product/product-context';
|
||||
import { ProductDetailsSection } from 'components/product/ProductDetailsSection';
|
||||
import { ProductGallery } from 'components/product/ProductGallery';
|
||||
import { HIDDEN_PRODUCT_TAG } from 'lib/constants';
|
||||
import { getProduct } from 'lib/shopify';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ handle: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
|
||||
const { url, width, height, altText: alt } = product.featuredImage || {};
|
||||
const indexable = !product.tags.includes(HIDDEN_PRODUCT_TAG);
|
||||
|
||||
return {
|
||||
title: product.seo.title || product.title,
|
||||
description: product.seo.description || product.description,
|
||||
robots: {
|
||||
index: indexable,
|
||||
follow: indexable,
|
||||
googleBot: {
|
||||
index: indexable,
|
||||
follow: indexable
|
||||
}
|
||||
},
|
||||
openGraph: url
|
||||
? {
|
||||
images: [
|
||||
{
|
||||
url,
|
||||
width,
|
||||
height,
|
||||
alt
|
||||
}
|
||||
]
|
||||
}
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProductPage(props: { params: Promise<{ handle: string }> }) {
|
||||
const params = await props.params;
|
||||
const product = await getProduct(params.handle);
|
||||
|
||||
if (!product) return notFound();
|
||||
|
||||
const productJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: product.title,
|
||||
description: product.description,
|
||||
image: product.featuredImage.url,
|
||||
offers: {
|
||||
'@type': 'AggregateOffer',
|
||||
availability: product.availableForSale
|
||||
? 'https://schema.org/InStock'
|
||||
: 'https://schema.org/OutOfStock',
|
||||
priceCurrency: product.priceRange.minVariantPrice.currencyCode,
|
||||
highPrice: product.priceRange.maxVariantPrice.amount,
|
||||
lowPrice: product.priceRange.minVariantPrice.amount
|
||||
}
|
||||
};
|
||||
|
||||
// If product has only one image, duplicate it to create the gallery effect
|
||||
const galleryImages = product.images.length === 1
|
||||
? Array(5).fill(product.images[0]).map(image => ({
|
||||
url: image.url,
|
||||
altText: image.altText,
|
||||
width: image.width,
|
||||
height: image.height
|
||||
}))
|
||||
: product.images;
|
||||
|
||||
return (
|
||||
<ProductProvider>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(productJsonLd)
|
||||
}}
|
||||
/>
|
||||
<div className="py-4">
|
||||
<div className={container}>
|
||||
<BackButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={container}>
|
||||
<div className="flex flex-col md:flex-row lg:gap-8">
|
||||
<div className="w-full lg:w-3/5">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="relative aspect-square h-full max-h-[600px] w-full overflow-hidden" />
|
||||
}
|
||||
>
|
||||
<ProductGallery
|
||||
images={galleryImages.slice(0, 5)}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="w-full lg:w-2/5 mt-8 lg:mt-0">
|
||||
<Suspense fallback={null}>
|
||||
<ProductDescription product={product} />
|
||||
<ProductDetailsSection />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProductProvider>
|
||||
);
|
||||
}
|
||||
11
app/products/page.tsx
Normal file
11
app/products/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ProductsPage } from "@/components/products/ProductsPage";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Products | Sent",
|
||||
description: "Explore our collection of ready-made gift boxes and baskets."
|
||||
};
|
||||
|
||||
export default function ProductsRoute() {
|
||||
return <ProductsPage />;
|
||||
}
|
||||
15
app/robots.ts
Normal file
15
app/robots.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export default function robots() {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*'
|
||||
}
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
host: baseUrl
|
||||
};
|
||||
}
|
||||
11
app/search/[collection]/opengraph-image.tsx
Normal file
11
app/search/[collection]/opengraph-image.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getCollection } from 'lib/shopify';
|
||||
import OpengraphImage from '../../../components/opengraph-image';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export default async function Image({ params }: { params: { collection: string } }) {
|
||||
const collection = await getCollection(params.collection);
|
||||
const title = collection?.seo?.title || collection?.title;
|
||||
|
||||
return await OpengraphImage({ title });
|
||||
}
|
||||
45
app/search/[collection]/page.tsx
Normal file
45
app/search/[collection]/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getCollection, getCollectionProducts } from 'lib/shopify';
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
import ProductGridItems from '@/components/layout/ProductGridItems';
|
||||
import Grid from 'components/grid';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ collection: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const collection = await getCollection(params.collection);
|
||||
|
||||
if (!collection) return notFound();
|
||||
|
||||
return {
|
||||
title: collection.seo?.title || collection.title,
|
||||
description:
|
||||
collection.seo?.description || collection.description || `${collection.title} products`
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CategoryPage(props: {
|
||||
params: Promise<{ collection: string }>;
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
const { sort } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
const products = await getCollectionProducts({ collection: params.collection, sortKey, reverse });
|
||||
|
||||
return (
|
||||
<section>
|
||||
{products.length === 0 ? (
|
||||
<p className="py-3 text-lg">{`No products found in this collection`}</p>
|
||||
) : (
|
||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
10
app/search/children-wrapper.tsx
Normal file
10
app/search/children-wrapper.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
// Ensure children are re-rendered when the search query changes
|
||||
export default function ChildrenWrapper({ children }: { children: React.ReactNode }) {
|
||||
const searchParams = useSearchParams();
|
||||
return <Fragment key={searchParams.get('q')}>{children}</Fragment>;
|
||||
}
|
||||
24
app/search/layout.tsx
Normal file
24
app/search/layout.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Footer } from 'components/layout/footer';
|
||||
import Collections from 'components/layout/search/collections';
|
||||
import FilterList from 'components/layout/search/filter';
|
||||
import { sorting } from 'lib/constants';
|
||||
import ChildrenWrapper from './children-wrapper';
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto flex max-w-screen-2xl flex-col gap-8 px-4 pb-4 md:flex-row">
|
||||
<div className="order-first w-full flex-none md:max-w-[125px]">
|
||||
<Collections />
|
||||
</div>
|
||||
<div className="order-last min-h-screen w-full md:order-none">
|
||||
<ChildrenWrapper>{children}</ChildrenWrapper>
|
||||
</div>
|
||||
<div className="order-none flex-none md:order-last md:w-[125px]">
|
||||
<FilterList list={sorting} title="Sort by" />
|
||||
</div>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
app/search/loading.tsx
Normal file
18
app/search/loading.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Grid from 'components/grid';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 h-6" />
|
||||
<Grid className="grid-cols-2 lg:grid-cols-3">
|
||||
{Array(12)
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<Grid.Item key={index} className="animate-pulse bg-neutral-100 dark:bg-neutral-800" />
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
app/search/page.tsx
Normal file
38
app/search/page.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import ProductGridItems from '@/components/layout/ProductGridItems';
|
||||
import Grid from 'components/grid';
|
||||
import { defaultSort, sorting } from 'lib/constants';
|
||||
import { getProducts } from 'lib/shopify';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Search',
|
||||
description: 'Search for products in the store.'
|
||||
};
|
||||
|
||||
export default async function SearchPage(props: {
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
const { sort, q: searchValue } = searchParams as { [key: string]: string };
|
||||
const { sortKey, reverse } = sorting.find((item) => item.slug === sort) || defaultSort;
|
||||
|
||||
const products = await getProducts({ sortKey, reverse, query: searchValue });
|
||||
const resultsText = products.length > 1 ? 'results' : 'result';
|
||||
|
||||
return (
|
||||
<>
|
||||
{searchValue ? (
|
||||
<p className="mb-4">
|
||||
{products.length === 0
|
||||
? 'There are no products that match '
|
||||
: `Showing ${products.length} ${resultsText} for `}
|
||||
<span className="font-bold">"{searchValue}"</span>
|
||||
</p>
|
||||
) : null}
|
||||
{products.length > 0 ? (
|
||||
<Grid className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ProductGridItems products={products} />
|
||||
</Grid>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
54
app/sitemap.ts
Normal file
54
app/sitemap.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getCollections, getPages, getProducts } from 'lib/shopify';
|
||||
import { validateEnvironmentVariables } from 'lib/utils';
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
type Route = {
|
||||
url: string;
|
||||
lastModified: string;
|
||||
};
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL
|
||||
? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
validateEnvironmentVariables();
|
||||
|
||||
const routesMap = [''].map((route) => ({
|
||||
url: `${baseUrl}${route}`,
|
||||
lastModified: new Date().toISOString()
|
||||
}));
|
||||
|
||||
const collectionsPromise = getCollections().then((collections) =>
|
||||
collections.map((collection) => ({
|
||||
url: `${baseUrl}${collection.path}`,
|
||||
lastModified: collection.updatedAt
|
||||
}))
|
||||
);
|
||||
|
||||
const productsPromise = getProducts({}).then((products) =>
|
||||
products.map((product) => ({
|
||||
url: `${baseUrl}/product/${product.handle}`,
|
||||
lastModified: product.updatedAt
|
||||
}))
|
||||
);
|
||||
|
||||
const pagesPromise = getPages().then((pages) =>
|
||||
pages.map((page) => ({
|
||||
url: `${baseUrl}/${page.handle}`,
|
||||
lastModified: page.updatedAt
|
||||
}))
|
||||
);
|
||||
|
||||
let fetchedRoutes: Route[] = [];
|
||||
|
||||
try {
|
||||
fetchedRoutes = (await Promise.all([collectionsPromise, productsPromise, pagesPromise])).flat();
|
||||
} catch (error) {
|
||||
throw JSON.stringify(error, null, 2);
|
||||
}
|
||||
|
||||
return [...routesMap, ...fetchedRoutes];
|
||||
}
|
||||
78
app/terms-of-service/page.tsx
Normal file
78
app/terms-of-service/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { container } from "@/lib/utils";
|
||||
|
||||
export default function TermsOfServicePage() {
|
||||
return (
|
||||
<div className={`${container} py-12`}>
|
||||
<Heading level={1} className="mb-8 text-center">Terms of Service</Heading>
|
||||
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<Text className="mb-6">
|
||||
Last updated: {new Date().toLocaleDateString()}
|
||||
</Text>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">1. Agreement to Terms</Heading>
|
||||
<Text className="mb-4">
|
||||
By accessing our website at <span className="font-medium">sentshop.com</span>, you are agreeing to be bound by these terms of service,
|
||||
all applicable laws and regulations, and agree that you are responsible for compliance with any
|
||||
applicable local laws. If you do not agree with any of these terms, you are prohibited from
|
||||
using or accessing this site.
|
||||
</Text>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">2. Use License</Heading>
|
||||
<Text className="mb-4">
|
||||
Permission is granted to temporarily download one copy of the materials (information or software)
|
||||
on SENT's website for personal, non-commercial transitory viewing only. This is the grant of a license, not a transfer of title,
|
||||
and under this license you may not:
|
||||
</Text>
|
||||
<ul className="list-disc pl-6 mb-6 space-y-2">
|
||||
<li>Modify or copy the materials;</li>
|
||||
<li>Use the materials for any commercial purpose, or for any public display (commercial or non-commercial);</li>
|
||||
<li>Attempt to decompile or reverse engineer any software contained on SENT's website;</li>
|
||||
<li>Remove any copyright or other proprietary notations from the materials; or</li>
|
||||
<li>Transfer the materials to another person or "mirror" the materials on any other server.</li>
|
||||
</ul>
|
||||
<Text className="mb-4">
|
||||
This license shall automatically terminate if you violate any of these restrictions and may be terminated by SENT at any time.
|
||||
Upon terminating your viewing of these materials or upon the termination of this license,
|
||||
you must destroy any downloaded materials in your possession whether in electronic or printed format.
|
||||
</Text>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">3. Disclaimer</Heading>
|
||||
<Text className="mb-4">
|
||||
The materials on SENT's website are provided on an 'as is' basis. SENT makes no warranties,
|
||||
expressed or implied, and hereby disclaims and negates all other warranties including, without limitation,
|
||||
implied warranties or conditions of merchantability, fitness for a particular purpose, or non-infringement of intellectual property
|
||||
or other violation of rights.
|
||||
</Text>
|
||||
<Text className="mb-4">
|
||||
Further, SENT does not warrant or make any representations concerning the accuracy, likely results,
|
||||
or reliability of the use of the materials on its website or otherwise relating to such materials or
|
||||
on any sites linked to this site.
|
||||
</Text>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">4. Limitations</Heading>
|
||||
<Text className="mb-4">
|
||||
In no event shall SENT or its suppliers be liable for any damages (including, without limitation,
|
||||
damages for loss of data or profit, or due to business interruption) arising out of the use or inability to use
|
||||
the materials on SENT's website, even if SENT or a SENT authorized representative has been notified orally or in writing
|
||||
of the possibility of such damage.
|
||||
</Text>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">5. Governing Law</Heading>
|
||||
<Text className="mb-4">
|
||||
These terms and conditions are governed by and construed in accordance with the laws of Croatia
|
||||
and you irrevocably submit to the exclusive jurisdiction of the courts in that location.
|
||||
</Text>
|
||||
|
||||
<Heading level={2} className="mb-4 mt-8">6. Contact Us</Heading>
|
||||
<Text className="mb-6">
|
||||
If you have any questions about these Terms, please contact us at:
|
||||
</Text>
|
||||
<Text className="font-medium mb-1">Email: info@sentshop.com</Text>
|
||||
<Text className="font-medium mb-1">Phone: +385 1 234 5678</Text>
|
||||
<Text className="font-medium mb-6">Address: Ilica 123, 10000 Zagreb, Croatia</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
app/test-boxes/page.tsx
Normal file
29
app/test-boxes/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ProductGrid } from "@/components/products/ProductGrid";
|
||||
import { Section } from "@/components/ui/Section";
|
||||
import { Heading, Text } from "@/components/ui/Typography";
|
||||
import { getBoxProducts } from "lib/shopify";
|
||||
|
||||
export default async function TestBoxesPage() {
|
||||
const boxes = await getBoxProducts({
|
||||
sortKey: 'CREATED_AT',
|
||||
reverse: true
|
||||
});
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<div className="mb-6">
|
||||
<Heading level={1}>Test Box Products</Heading>
|
||||
<Text>Found {boxes.length} box products. This is a temporary test page.</Text>
|
||||
</div>
|
||||
|
||||
{boxes.length === 0 ? (
|
||||
<div className="p-12 text-center bg-gray-100 rounded-lg">
|
||||
<Text size="lg" className="font-semibold">No box products found</Text>
|
||||
<Text className="mt-2">Try adding products with 'box' in the title or with the 'box' tag.</Text>
|
||||
</div>
|
||||
) : (
|
||||
<ProductGrid products={boxes} title="Box Products" />
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user