diff options
| author | Rafi Zadanly <zadanlyr@gmail.com> | 2023-11-09 15:40:16 +0700 |
|---|---|---|
| committer | Rafi Zadanly <zadanlyr@gmail.com> | 2023-11-09 15:40:16 +0700 |
| commit | be0f537dc4fe384eef09436833c6407e6482c16d (patch) | |
| tree | 194b1ad3f34396cb8149075bbbd38b854aedf361 /src/app | |
| parent | 5d5401ae36e7e0c8eb38ccd943c1aa44a9573d35 (diff) | |
Initial commit
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/(desktop-screen)/result/page.tsx | 14 | ||||
| -rw-r--r-- | src/app/(mobile-screen)/layout.tsx | 12 | ||||
| -rw-r--r-- | src/app/(mobile-screen)/login/page.tsx | 8 | ||||
| -rw-r--r-- | src/app/(mobile-screen)/page.tsx | 16 | ||||
| -rw-r--r-- | src/app/api/auth/login/route.tsx | 36 | ||||
| -rw-r--r-- | src/app/api/company/route.tsx | 8 | ||||
| -rw-r--r-- | src/app/api/hash/route.tsx | 10 | ||||
| -rw-r--r-- | src/app/api/location/route.tsx | 27 | ||||
| -rw-r--r-- | src/app/api/product/import/route.tsx | 27 | ||||
| -rw-r--r-- | src/app/api/product/route.tsx | 48 | ||||
| -rw-r--r-- | src/app/api/stock-opname/location/route.tsx | 57 | ||||
| -rw-r--r-- | src/app/api/stock-opname/quantity/route.tsx | 34 | ||||
| -rw-r--r-- | src/app/api/stock-opname/route.tsx | 218 | ||||
| -rw-r--r-- | src/app/globals.css | 27 | ||||
| -rw-r--r-- | src/app/layout.tsx | 24 | ||||
| -rw-r--r-- | src/app/page.tsx | 113 |
16 files changed, 532 insertions, 147 deletions
diff --git a/src/app/(desktop-screen)/result/page.tsx b/src/app/(desktop-screen)/result/page.tsx new file mode 100644 index 0000000..8c96272 --- /dev/null +++ b/src/app/(desktop-screen)/result/page.tsx @@ -0,0 +1,14 @@ +import Authenticated from "@/common/components/Authenticated" +import Result from "@/modules/result" + +const ResultPage = () => { + return ( + <Authenticated> + <main className="px-4 pt-6 bg-white"> + <Result /> + </main> + </Authenticated> + ) +} + +export default ResultPage
\ No newline at end of file diff --git a/src/app/(mobile-screen)/layout.tsx b/src/app/(mobile-screen)/layout.tsx new file mode 100644 index 0000000..f44be2c --- /dev/null +++ b/src/app/(mobile-screen)/layout.tsx @@ -0,0 +1,12 @@ +import ScreenContainer from '@/common/components/ScreenContainer' +import React from 'react' + +const layout = ({ children }: { children: React.ReactNode }) => { + return ( + <ScreenContainer> + {children} + </ScreenContainer> + ) +} + +export default layout
\ No newline at end of file diff --git a/src/app/(mobile-screen)/login/page.tsx b/src/app/(mobile-screen)/login/page.tsx new file mode 100644 index 0000000..d518e2d --- /dev/null +++ b/src/app/(mobile-screen)/login/page.tsx @@ -0,0 +1,8 @@ +import Login from '@/modules/login' +import React from 'react' + +const LoginPage = () => { + return <Login /> +} + +export default LoginPage
\ No newline at end of file diff --git a/src/app/(mobile-screen)/page.tsx b/src/app/(mobile-screen)/page.tsx new file mode 100644 index 0000000..430a195 --- /dev/null +++ b/src/app/(mobile-screen)/page.tsx @@ -0,0 +1,16 @@ +import Authenticated from "@/common/components/Authenticated"; +import ProfileCard from "@/modules/profile-card"; +import StockOpname from "@/modules/stock-opname"; +import { Spacer } from "@nextui-org/react"; + +export default function HomePage() { + return ( + <Authenticated> + <main className="px-4 pt-10"> + <ProfileCard /> + <Spacer y={6} /> + <StockOpname /> + </main> + </Authenticated> + ) +} diff --git a/src/app/api/auth/login/route.tsx b/src/app/api/auth/login/route.tsx new file mode 100644 index 0000000..d4da662 --- /dev/null +++ b/src/app/api/auth/login/route.tsx @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; +import { cookies } from "next/headers" +import { Credential } from "@/common/types/auth" +import bcrypt from "bcrypt"; +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET as string + +export async function POST(request: NextRequest) { + const body = await request.json() + + const user = await prisma.user.findUnique({ + where: { username: body.username }, + include: { + company: true + } + }) + + if (!user) { + return NextResponse.json({ error: 'User not found' }, { status: 404 }) + } + + if (!await bcrypt.compare(body.password, user.password)) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const credential: Credential = { + ...user, + token: jwt.sign(user, JWT_SECRET, { expiresIn: '10y' }) + } + + cookies().set('credential', JSON.stringify(credential)) + + return NextResponse.json(credential) +}
\ No newline at end of file diff --git a/src/app/api/company/route.tsx b/src/app/api/company/route.tsx new file mode 100644 index 0000000..c970108 --- /dev/null +++ b/src/app/api/company/route.tsx @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; +import { prisma } from "prisma/client"; + +export async function GET() { + const companies = await prisma.company.findMany() + + return NextResponse.json(companies) +}
\ No newline at end of file diff --git a/src/app/api/hash/route.tsx b/src/app/api/hash/route.tsx new file mode 100644 index 0000000..2727e1f --- /dev/null +++ b/src/app/api/hash/route.tsx @@ -0,0 +1,10 @@ +import { NextRequest, NextResponse } from "next/server"; +import bcrypt from "bcrypt" + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const password = searchParams.get('password') ?? ''; + const hash = await bcrypt.hash(password, 10); + + return NextResponse.json({ hash }); +}
\ No newline at end of file diff --git a/src/app/api/location/route.tsx b/src/app/api/location/route.tsx new file mode 100644 index 0000000..452a85d --- /dev/null +++ b/src/app/api/location/route.tsx @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; +import { Credential } from "@/common/types/auth" + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const search = searchParams.get('search'); + + const credentialStr = request.cookies.get('credential')?.value + const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null + + if (!credential) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { companyId } = credential + + const locations = await prisma.location.findMany({ + where: { + companyId, + name: { contains: search ?? '' } + }, + take: 20 + }) + + return NextResponse.json(locations) +}
\ No newline at end of file diff --git a/src/app/api/product/import/route.tsx b/src/app/api/product/import/route.tsx new file mode 100644 index 0000000..7358205 --- /dev/null +++ b/src/app/api/product/import/route.tsx @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; +import * as XLSX from "xlsx"; + +export async function POST(request: NextRequest) { + const body = await request.arrayBuffer(); + const workbook = XLSX.read(body, { type: 'buffer' }) + const worksheetName = workbook.SheetNames[0] + const worksheet = workbook.Sheets[worksheetName] + const fileData: any[] = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) + fileData.shift(); + + const newProducts = fileData.map(row => ({ + id: undefined, + isDifferent: false, + name: row[0], + barcode: row[1], + itemCode: row[2], + onhandQty: row[3], + differenceQty: row[4], + companyId: row[5], + })); + + await prisma.product.createMany({ data: newProducts }) + + return NextResponse.json(true) +}
\ No newline at end of file diff --git a/src/app/api/product/route.tsx b/src/app/api/product/route.tsx new file mode 100644 index 0000000..1161a4e --- /dev/null +++ b/src/app/api/product/route.tsx @@ -0,0 +1,48 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; +import { Credential } from "@/common/types/auth" + +export async function GET(request: NextRequest) { + const PAGE_SIZE = 30; + + const searchParams = request.nextUrl.searchParams; + const type = searchParams.get('type') + const search = searchParams.get('search'); + const page = searchParams.get('page'); + const intPage: number = page ? parseInt(page) : 1; + + const credentialStr = request.cookies.get('credential')?.value + const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null + + if (!credential) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { companyId } = credential + + const where = { + AND: { + OR: [ + { name: { contains: search ?? '' } }, + { barcode: { contains: search ?? '' } }, + { itemCode: { contains: search ?? '' } }, + ], + companyId: type == 'all' ? undefined : companyId + } + } + + const products = await prisma.product.findMany({ + where, + include: { company: true }, + take: PAGE_SIZE, + skip: (intPage - 1) * PAGE_SIZE + }) + + const count = await prisma.product.count({ where }) + const pagination = { + page: intPage, + totalPage: Math.ceil(count / PAGE_SIZE), + } + + return NextResponse.json({ products, ...pagination }) +}
\ No newline at end of file diff --git a/src/app/api/stock-opname/location/route.tsx b/src/app/api/stock-opname/location/route.tsx new file mode 100644 index 0000000..1009486 --- /dev/null +++ b/src/app/api/stock-opname/location/route.tsx @@ -0,0 +1,57 @@ +import { DetailTeam, StockOpnameLocationRes } from "@/common/types/stockOpname"; +import { Location, Team } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; + + + +const getOpnameQuantity = async (where: { locationId: number, productId: number }) => { + const detailTeam: DetailTeam = { + COUNT1: { quantity: undefined, user: undefined }, + COUNT2: { quantity: undefined, user: undefined }, + VERIFICATION: { quantity: undefined, user: undefined }, + } + for (const team of Object.keys(Team)) { + const opname = await prisma.stockOpname.findFirst({ + where: { ...where, team: team as Team }, + select: { quantity: true, user: true }, + }) + if (!opname) continue + detailTeam[team as Team]['quantity'] = opname?.quantity + detailTeam[team as Team]['user'] = opname?.user + } + return detailTeam +} + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const productId = searchParams.get('productId') ?? ''; + const companyId = searchParams.get('companyId'); + const intProductId = parseInt(productId); + + if (!companyId) { + return NextResponse.json({ error: 'Bad Request. Missing companyId' }, { status: 400 }) + } + + const intCompanyId = parseInt(companyId); + + const opnameLocByProduct = await prisma.stockOpname.groupBy({ + by: ['locationId'], + where: { productId: intProductId, companyId: intCompanyId }, + }) + + const locationIds = opnameLocByProduct.map((opname) => opname.locationId) + + const result: StockOpnameLocationRes[] = [] + + for (const locationId of locationIds) { + const detail = await getOpnameQuantity({ locationId, productId: intProductId }) + const location = await prisma.location.findFirst({ + where: { id: locationId, companyId: intCompanyId } + }) + if (!location) continue + result.push({ ...location, ...detail }) + } + + return NextResponse.json(result) +}
\ No newline at end of file diff --git a/src/app/api/stock-opname/quantity/route.tsx b/src/app/api/stock-opname/quantity/route.tsx new file mode 100644 index 0000000..621297f --- /dev/null +++ b/src/app/api/stock-opname/quantity/route.tsx @@ -0,0 +1,34 @@ +import { Credential } from "@/common/types/auth"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; + +export async function GET(request: NextRequest) { + const credentialStr = request.cookies.get('credential')?.value + const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null + + if (!credential) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const searchParams = request.nextUrl.searchParams; + let locationId = searchParams.get('locationId'); + let productId = searchParams.get('productId'); + + if (!locationId || !productId) return NextResponse.json({ error: 'Bad Request. Missing locationId and productId' }, { status: 400 }) + let intLocationId = parseInt(locationId) + let intProductId = parseInt(productId) + + const { companyId, team } = credential + + const query = { + locationId: intLocationId, + productId: intProductId, + companyId, + team + } + + const stockOpname = await prisma.stockOpname.findFirst({ + where: query, + select: { id: true, quantity: true, user: true } + }) + + return NextResponse.json(stockOpname) +}
\ No newline at end of file diff --git a/src/app/api/stock-opname/route.tsx b/src/app/api/stock-opname/route.tsx new file mode 100644 index 0000000..50feacd --- /dev/null +++ b/src/app/api/stock-opname/route.tsx @@ -0,0 +1,218 @@ +import { Credential } from "@/common/types/auth"; +import { StockOpnameLocationRes, StockOpnameRequest } from "@/common/types/stockOpname"; +import { Team } from "@prisma/client"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "prisma/client"; + +type Quantity = { + [key in keyof typeof Team]: number | null +} + +const calculateOpnameQuantity = async ( + where: { + productId: number, + companyId: number + } +): Promise<Quantity> => { + const quantity: Quantity = { + COUNT1: null, + COUNT2: null, + VERIFICATION: null, + } + for (const team of Object.values(Team)) { + const opnameQty = await prisma.stockOpname.groupBy({ + by: ['productId', 'team'], + _sum: { quantity: true }, + where: { team, ...where } + }) + if (opnameQty.length === 0) continue + quantity[team] = opnameQty[0]._sum.quantity + } + return quantity +} + +export async function GET(request: NextRequest) { + const PAGE_SIZE = 30; + + const params = request.nextUrl.searchParams + const companyId = params.get('companyId') + const search = params.get('search') + const page = params.get('page') ?? null; + const intPage = page ? parseInt(page) : 1; + + if (!companyId) { + return NextResponse.json({ error: 'Bad Request. Missing companyId' }, { status: 400 }) + } + + const where = { + AND: { + stockOpnames: { some: {} }, + companyId: parseInt(companyId), + OR: [ + { name: { contains: search ?? '' } }, + { itemCode: { contains: search ?? '' } }, + { barcode: { contains: search ?? '' } }, + ] + } + } + + const products = await prisma.product.findMany({ + skip: (intPage - 1) * PAGE_SIZE, + take: PAGE_SIZE, + where, + select: { + id: true, + name: true, + itemCode: true, + barcode: true, + onhandQty: true, + differenceQty: true, + isDifferent: true + } + }) + + const productCount = await prisma.product.count({ where }) + + const pagination = { + page: intPage, + totalPage: Math.ceil(productCount / PAGE_SIZE), + } + + type ProductWithSum = typeof products[0] & { quantity: Quantity } + + const productsWithSum: ProductWithSum[] = [] + + for (const product of products) { + const quantity = await calculateOpnameQuantity({ + productId: product.id, + companyId: parseInt(companyId) + }) + productsWithSum.push({ ...product, quantity }) + } + + return NextResponse.json({ + result: productsWithSum, + ...pagination + }) +} + +export async function POST(request: NextRequest) { + const credentialStr = request.cookies.get('credential')?.value + const credential: Credential | null = credentialStr ? JSON.parse(credentialStr) : null + + if (!credential) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const body: StockOpnameRequest = await request.json() + + const { companyId, team } = credential + + const query = { + locationId: body.location, + productId: body.product, + companyId, + team + } + + const stockOpname = await prisma.stockOpname.findFirst({ + where: query + }) + + const data = { + ...query, + userId: credential.id, + quantity: body.quantity, + isDifferent: false + } + + let newStockOpname = null + + if (!stockOpname) { + newStockOpname = await prisma.stockOpname.create({ data }) + } else { + newStockOpname = await prisma.stockOpname.update({ + where: { id: stockOpname.id }, + data + }) + } + + computeIsDifferent({ productId: body.product, companyId: companyId }) + + return NextResponse.json(newStockOpname) +} + +const SELF_HOST = process.env.SELF_HOST as string + +const computeIsDifferent = async ({ + companyId, + productId +}: { + companyId: number, + productId: number +}) => { + const totalQty: { [key in keyof typeof Team]: number | null } = { + COUNT1: null, + COUNT2: null, + VERIFICATION: null + } + + const searchParams = new URLSearchParams({ + companyId: companyId.toString(), + productId: productId.toString() + }) + + const stockOpnamesFetch = await fetch(`${SELF_HOST}/api/stock-opname/location?${searchParams}`) + const stockOpnames: StockOpnameLocationRes[] = await stockOpnamesFetch.json() + + const count2Count = await prisma.stockOpname.count({ + where: { companyId, productId, team: 'COUNT2' } + }) + + const isCount2Counted: boolean = count2Count > 0 + + let isDifferent: boolean = false + + for (const opname of stockOpnames) { + let { COUNT1, COUNT2, VERIFICATION } = opname + + if (!totalQty['COUNT1'] && COUNT1.quantity) totalQty['COUNT1'] = 0 + if (!totalQty['COUNT2'] && COUNT2.quantity) totalQty['COUNT2'] = 0 + if (!totalQty['VERIFICATION'] && VERIFICATION.quantity) totalQty['VERIFICATION'] = 0 + + if (totalQty['COUNT1'] !== null && COUNT1.quantity) totalQty['COUNT1'] += COUNT1.quantity + if (totalQty['COUNT2'] !== null && COUNT2.quantity) totalQty['COUNT2'] += COUNT2.quantity + if (totalQty['VERIFICATION'] !== null && VERIFICATION.quantity) totalQty['VERIFICATION'] += VERIFICATION.quantity + + if (isCount2Counted && COUNT1.quantity != COUNT2.quantity) { + isDifferent = true + } + } + + const product = await prisma.product.findFirst({ + where: { id: productId } + }) + if (!product) return + + const onhandQty = product?.onhandQty || 0 + + if (!isDifferent) { + if ( + (typeof totalQty['VERIFICATION'] === 'number' && totalQty['VERIFICATION'] > 0) || + totalQty['COUNT2'] == onhandQty || + totalQty['COUNT1'] == onhandQty || + totalQty['COUNT1'] == totalQty['COUNT2'] + ) { + isDifferent = false + } else { + isDifferent = true + } + + if (isCount2Counted && totalQty['COUNT1'] != onhandQty) { + isDifferent = true + } + } + + await prisma.product.update({ + where: { id: product.id }, + data: { isDifferent } + }) +}
\ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css deleted file mode 100644 index fd81e88..0000000 --- a/src/app/globals.css +++ /dev/null @@ -1,27 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - } -} - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 40e027f..933e308 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,12 +1,15 @@ import type { Metadata } from 'next' -import { Inter } from 'next/font/google' -import './globals.css' - -const inter = Inter({ subsets: ['latin'] }) +import '@/common/styles/globals.css' +import clsxm from '@/common/libs/clsxm' +import { inter } from '@/common/styles/fonts' +import UIProvider from '@/common/contexts/UIProvider' +import QueryProvider from '@/common/contexts/QueryProvider' +import { Toaster } from 'react-hot-toast' export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: 'FIN Stock Opname', + description: 'FIN Stock Opname', + viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0', } export default function RootLayout({ @@ -16,7 +19,14 @@ export default function RootLayout({ }) { return ( <html lang="en"> - <body className={inter.className}>{children}</body> + <body className={clsxm(inter.variable)}> + <Toaster /> + <UIProvider> + <QueryProvider> + {children} + </QueryProvider> + </UIProvider> + </body> </html> ) } diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index e38c626..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import Image from 'next/image' - -export default function Home() { - return ( - <main className="flex min-h-screen flex-col items-center justify-between p-24"> - <div className="z-10 max-w-5xl w-full items-center justify-between font-mono text-sm lg:flex"> - <p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30"> - Get started by editing - <code className="font-mono font-bold">src/app/page.tsx</code> - </p> - <div className="fixed bottom-0 left-0 flex h-48 w-full items-end justify-center bg-gradient-to-t from-white via-white dark:from-black dark:via-black lg:static lg:h-auto lg:w-auto lg:bg-none"> - <a - className="pointer-events-none flex place-items-center gap-2 p-8 lg:pointer-events-auto lg:p-0" - href="https://vercel.com?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - By{' '} - <Image - src="/vercel.svg" - alt="Vercel Logo" - className="dark:invert" - width={100} - height={24} - priority - /> - </a> - </div> - </div> - - <div className="relative flex place-items-center before:absolute before:h-[300px] before:w-[480px] before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-[240px] after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 before:lg:h-[360px] z-[-1]"> - <Image - className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert" - src="/next.svg" - alt="Next.js Logo" - width={180} - height={37} - priority - /> - </div> - - <div className="mb-32 grid text-center lg:max-w-5xl lg:w-full lg:mb-0 lg:grid-cols-4 lg:text-left"> - <a - href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Docs{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Find in-depth information about Next.js features and API. - </p> - </a> - - <a - href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Learn{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Learn about Next.js in an interactive course with quizzes! - </p> - </a> - - <a - href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Templates{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Explore the Next.js 13 playground. - </p> - </a> - - <a - href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app" - className="group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30" - target="_blank" - rel="noopener noreferrer" - > - <h2 className={`mb-3 text-2xl font-semibold`}> - Deploy{' '} - <span className="inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none"> - -> - </span> - </h2> - <p className={`m-0 max-w-[30ch] text-sm opacity-50`}> - Instantly deploy your Next.js site to a shareable URL with Vercel. - </p> - </a> - </div> - </main> - ) -} |
